
1. 为什么要在玩家真机上做 Profiler1.1 从一次线上卡顿排查说起去年我们的一款动作手游在部分中低端安卓机上频繁出现战斗场景掉帧策划反馈“玩家说放技能就卡”测试组在几台主力测试机上反复跑却复现不出来。我们一开始怀疑是特效粒子数超标把技能特效砍了30%结果线上投诉没降多少反而美术效果被玩家骂了一顿。后来借了几台玩家反馈最多的机型——一台骁龙660、一台天玑700、一台麒麟810——装上带符号的包用真机Profiler抓了一轮才发现问题根本不在特效而是在技能释放瞬间触发的资源同步加载一个技能要动态加载十几个贴图和两个音效主线程被IO阻塞了将近80毫秒正好卡在技能前摇的关键帧上。这件事给我最大的教训是编辑器里的Profiler和真机上的Profiler看到的是两个世界。编辑器跑在PC上CPU单核性能是手机的几倍内存带宽、磁盘IO、GPU驱动全都不一样你在编辑器里看到的“耗时2ms”的函数到真机上可能就是20ms。所以“能跑在玩家真机上的Profiler”这个命题核心不是做一个多炫酷的工具而是让性能数据从真实设备、真实场景、真实玩家操作中回流回来。1.2 真机Profiler到底解决什么问题简单说它要解决三件事。第一是环境真实性玩家手机的CPU调度策略、温控降频、后台应用抢占、存储读写速度这些在开发机上永远模拟不准。第二是场景真实性玩家可能一边充电一边玩、可能在地铁里网络抖动、可能连续玩了两个小时导致机身发热这些边界场景测试组很难穷举。第三是数据规模你不可能给每个测试机型都配一台采集设备但你可以让成千上万台玩家设备在后台低开销地采集关键指标把“偶发卡顿”变成“可统计的分布”。我见过不少团队的做法是接一个第三方APM SDK看个FPS曲线和内存曲线就完事了。但真到了要定位具体函数的时候那些聚合数据就不够用了。你需要的是带调用栈的采样数据能告诉你“卡顿的那一帧CPU时间到底花在哪个函数上”。这就是Profiler和普通监控的本质区别监控告诉你“病了”Profiler告诉你“病灶在哪”。1.3 适合谁来读这篇内容如果你是在Unity、Unreal或者自研引擎上做移动端性能优化的同学这篇内容应该对你有直接帮助。如果你是用Tracy、perf这类工具做桌面端分析、想把它搬到移动端的同学里面的采集链路设计思路也可以参考。哪怕你暂时不打算自己造轮子只是想搞清楚“真机采样到底是怎么一回事”读完至少能让你在跟工具团队提需求的时候知道哪些指标是真正有价值的、哪些坑是绕不过去的。2. 整体方案设计采样、传输与符号化2.1 为什么选采样而不是埋点做性能采集有两条路一条是埋点计时在函数入口和出口插桩记录每次调用的耗时另一条是采样以固定频率中断程序抓取当前调用栈统计各函数出现的次数。埋点的好处是精确能拿到每次调用的真实耗时和调用次数坏处是开销大插桩本身会改变程序行为而且对已经编译好的引擎代码很难全面覆盖。采样的逻辑则完全不同。它像是一个“随机抽查员”每隔一段时间比如1毫秒暂停一下程序问一句“你现在在干嘛”把当前的函数调用链记下来。单次采样的开销很小而且不需要修改业务代码。缺点是它只能给出统计意义上的分布不能告诉你某一次调用的精确耗时。但对于“找出热点函数”这个目标来说采样足够了——如果一个函数在1000次采样里出现了300次那它大概率就是瓶颈。我最终选的是基于信号的低频采样方案采样频率定在1000Hz左右。这个频率的选取有讲究太低比如100Hz会漏掉短小的热点函数太高比如10000Hz在移动端会带来明显的CPU开销和电量消耗。1000Hz意味着每毫秒采一次对于大多数帧率在30到60帧的游戏来说一帧内能采到16到33个样本足够画出有意义的火焰图了。2.2 采集链路的分层设计整个链路我分成了四层每一层都有明确的职责边界这样任何一层出问题都不会把整个采集搞崩。第一层是采样触发层负责在目标线程上按固定间隔触发采样信号。在Android上可以用SIGPROF或者SIGURGiOS上可以用SIGPROF。这里要注意信号的选择不能和系统或其他库冲突我实测下来SIGPROF在两端都比较干净。第二层是栈回溯层收到信号后立刻抓取当前线程的调用栈。这是整个链路里最脆弱的一环因为信号处理函数里能做的事情非常有限不能分配内存、不能加锁、不能调用大多数系统API。我的做法是在信号处理函数里只做最原始的栈指针和帧指针读取把原始数据写进一个预分配的环形缓冲区剩下的解析工作交给后台线程。第三层是数据聚合层后台线程从环形缓冲区取出原始栈数据做去重和计数把“同一个调用栈出现了多少次”统计出来。这一步会大幅压缩数据量一个采样周期内可能产生几千条原始记录聚合后可能只剩几十个唯一的调用栈。第四层是传输与符号化层把聚合后的数据包含地址和计数打包上传在服务端用符号表把地址翻译成函数名。符号表不上传到客户端是为了减小包体也避免暴露过多内部信息。2.3 为什么符号化要放在服务端这里单独说一下符号化。移动端的二进制文件不管是.so还是可执行文件都很大一个Unity的libunity.so动辄几十兆如果每个玩家设备都带着符号表去解析包体会膨胀得没法看。而且符号化本身是个CPU密集操作放在客户端做会跟游戏抢资源。我的做法是客户端只上传模块ID加相对地址服务端根据模块ID找到对应版本的符号表文件用addr2line或者自己写的解析器把地址翻译成“函数名行号”。模块ID我用的是构建时生成的UUID每次出包都不一样这样能保证符号表版本和客户端二进制严格对应。踩过的坑是有一次构建脚本没更新UUID导致线上采集的数据全部符号化错位查了一整天才发现是构建缓存的问题。所以现在我的构建流程里加了一步强制校验UUID不匹配直接报错。3. 核心细节解析栈回溯与低开销采集3.1 栈回溯的三种方式和取舍栈回溯是Profiler的核心做不好这一块后面全是空中楼阁。常见的方式有三种。第一种是帧指针回溯依赖编译器生成的帧指针链。优点是实现简单、速度快在信号处理函数里也能安全执行。缺点是现代编译器为了优化性能默认会省略帧指针-fomit-frame-pointer你得在编译选项里强制保留。对于第三方库和系统库你没法控制它们的编译选项所以帧指针链经常在中间断掉。第二种是DWARF展开利用调试信息里的CFICall Frame Information来还原调用栈。优点是准确不依赖帧指针。缺点是解析CFI需要读内存、查表在信号处理函数里做这些操作风险很高而且调试信息本身也会增大包体。第三种是栈扫描暴力扫描栈内存把看起来像返回地址的值都当成候选。优点是啥都不依赖缺点是误报率高会把一些数据误认成地址。我最终采用的是混合方案优先走帧指针回溯遇到断链时回退到栈扫描同时用模块地址范围做过滤把明显不是代码地址的候选值剔除掉。实测下来在Unity的IL2CPP环境下帧指针回溯能覆盖大约70%的栈深度剩下的靠栈扫描补齐整体准确率能满足定位热点的需求。3.2 信号安全那些不能踩的雷在信号处理函数里写代码跟在普通函数里写代码完全是两码事。我踩过的雷包括在信号处理函数里调用了malloc导致死锁、调用了pthread_mutex_lock导致整个进程卡死、调用了printf导致输出错乱。这些问题的根源是信号可能在任何时刻打断任何函数如果被中断的函数正好持有某个锁而你在信号处理函数里又去抢同一个锁就会死锁。所以我的信号处理函数里只做这几件事读取栈指针和帧指针、把原始数据写进预分配的环形缓冲区、更新写指针。环形缓冲区的大小是固定的写满就覆盖最旧的数据绝不动态分配。读取栈内存的时候也要小心因为栈指针可能指向非法地址我加了一层sigsetjmp/siglongjmp保护遇到非法访问就跳过这次采样。提示信号处理函数里能用的函数非常有限POSIX标准里明确列出了“异步信号安全”的函数清单写之前一定要对照检查别凭感觉来。3.3 采样频率与开销的平衡采样频率直接决定了开销和数据质量。我做过一组对比测试在同一台骁龙865设备上跑同一个战斗场景记录不同采样频率下的帧率下降和CPU占用。采样频率平均帧率下降额外CPU占用单帧样本数60帧100Hz0.3%0.5%1.7500Hz1.2%1.8%8.31000Hz2.5%3.2%16.72000Hz6.8%7.5%33.35000Hz18.2%19.1%83.3从数据看1000Hz是一个比较舒服的平衡点帧率下降控制在3%以内单帧样本数足够画出有意义的火焰图。如果只是做粗粒度的热点定位500Hz也够用。2000Hz以上就有点得不偿失了除非你要抓的是极短促的卡顿尖峰。另外要注意的是采样频率不是越高越好还有一个奈奎斯特采样定理的约束在起作用。简单说如果你的采样频率是1000Hz那么你能准确捕捉到的最快变化是500Hz。对于游戏来说一帧16毫秒1000Hz采样意味着每帧采16次能分辨出帧内的时间分布。但如果某个热点函数只执行了0.5毫秒1000Hz采样有50%的概率漏掉它。所以对于“找热点”这个目标1000Hz够用对于“抓尖峰”这个目标可能需要配合其他手段。4. 实操过程从零搭建一个真机采样链路4.1 环境准备与编译选项先说编译选项这是最容易被忽略但最影响结果的一步。以Android NDK为例我用的编译参数是-fno-omit-frame-pointer -funwind-tables -g -fno-optimize-sibling-calls-fno-omit-frame-pointer保证帧指针链完整-funwind-tables生成展开表作为兜底-g保留调试信息但发布时符号表要单独剥离-fno-optimize-sibling-calls防止尾调用优化把栈帧合并掉。这几个选项会让包体增大一些但换来的是可靠的栈回溯我觉得值。iOS端类似Xcode里要把Debug Information Format设为DWARF with dSYM File并且关闭Strip Debug Symbols During Copy。发布时用dsymutil把符号表单独导出不要打进包里。4.2 采样器的初始化与启动采样器的初始化分几步。第一步是注册信号处理函数用sigaction而不是signal因为sigaction能更精细地控制信号行为。第二步是分配环形缓冲区大小我一般设为采样频率 × 最大采样时长 × 每条记录大小比如1000Hz采60秒每条记录256字节那就是大约15MB。第三步是启动一个后台线程负责从环形缓冲区取数据并聚合。启动采样的时机也有讲究。我一般是在游戏进入战斗场景前启动战斗结束后停止避免在主菜单这种低负载场景浪费资源。启动方式是在目标线程上设置一个定时器用setitimer或者timer_create每隔1毫秒发一次SIGPROF。struct sigaction sa; sa.sa_sigaction sample_handler; sa.sa_flags SA_SIGINFO | SA_RESTART; sigemptyset(sa.sa_mask); sigaction(SIGPROF, sa, NULL); struct itimerval timer; timer.it_interval.tv_sec 0; timer.it_interval.tv_usec 1000; timer.it_value timer.it_interval; setitimer(ITIMER_PROF, timer, NULL);这里用ITIMER_PROF而不是ITIMER_REAL是因为ITIMER_PROF只在进程消耗CPU时间时计时进程被调度出去的时候不计时这样采样点更集中在真正执行代码的时刻。4.3 栈数据的聚合与压缩原始栈数据是一堆地址序列直接上传的话数据量太大。我的聚合策略是把每个采样点的调用栈转换成一个哈希值用哈希表统计每个调用栈出现的次数。哈希函数我用的是FNV-1a简单快速碰撞率也能接受。聚合后的数据结构大概是这样的一个调用栈ID对应一个地址数组和一个计数。上传的时候只传调用栈ID、地址数组、计数以及模块ID列表。服务端收到后先用模块ID找到符号表把地址翻译成函数名再按计数排序生成火焰图。这里有个细节地址要转成模块内相对地址再上传因为每次加载模块的基地址可能不同ASLR。相对地址等于绝对地址减去模块基地址模块基地址可以在采样时通过读取/proc/self/maps获取或者用dl_iterate_phdr遍历。4.4 符号化服务的搭建服务端符号化我用的是Python加pyelftools库核心逻辑就是解析ELF文件的符号表建立地址到函数名的映射。对于有调试信息的文件还能拿到行号。为了提高查询速度我会把符号表预处理成一个按地址排序的数组查询时用二分查找。import bisect class SymbolTable: def __init__(self, elf_path): self.addrs [] self.names [] elf ELFFile(open(elf_path, rb)) for sym in elf.get_section_by_name(.symtab).iter_symbols(): if sym[st_info][type] STT_FUNC: self.addrs.append(sym[st_value]) self.names.append(sym.name) paired sorted(zip(self.addrs, self.names)) self.addrs, self.names zip(*paired) def lookup(self, addr): idx bisect.bisect_right(self.addrs, addr) - 1 if idx 0: return self.names[idx] return unknown这套东西跑起来之后从玩家设备上传数据到服务端生成火焰图整个链路大概在几分钟内完成。对于定位线上卡顿来说这个延迟是可以接受的。5. 常见问题与排查技巧实录5.1 采样数据全是unknown怎么办这是最常见的问题十有八九是符号化环节出了问题。排查顺序是这样的先确认客户端上传的模块ID和服务端符号表的模块ID是否一致我遇到过构建脚本没更新ID导致全部错位的情况。再确认地址是不是相对地址如果传的是绝对地址符号化肯定对不上。最后确认符号表本身有没有问题可以用nm或者readelf命令手动查一个地址看看能不能查到对应的函数名。还有一种情况是栈回溯本身失败了抓到的地址全是垃圾数据。这时候要检查编译选项有没有加-fno-omit-frame-pointer以及信号处理函数里的栈读取逻辑有没有越界保护。5.2 采样导致游戏卡顿或崩溃如果采样一开就卡先看采样频率是不是太高了。1000Hz在大多数设备上没问题但在一些低端机上可能扛不住可以动态降频到500Hz。如果采样一开就崩大概率是信号处理函数里做了不安全操作比如调用了非异步信号安全的函数或者环形缓冲区溢出导致内存越界。我踩过的一个坑是在信号处理函数里读取栈内存时没有做地址合法性检查结果读到了未映射的地址直接触发SIGSEGV。后来加了sigsetjmp保护遇到非法访问就longjmp回安全点问题就解决了。5.3 火焰图看起来不对劲火焰图不对劲通常有两种表现一种是某个函数占比异常高比如memcpy占了80%这可能是采样偏差导致的比如采样信号总是落在某个循环里。另一种是调用关系断裂比如A调用B但火焰图上A和B是平级的。前者可以通过增加采样时长、多次采样取平均来缓解后者基本是栈回溯断链导致的需要检查帧指针链是否完整。还有一个容易被忽略的点是内联函数。编译器会把小函数内联到调用者里采样时你看到的是调用者的地址但符号化后可能显示成被内联的函数名。这个不是bug是正常现象分析的时候要心里有数。5.4 常见问题速查表现象可能原因排查方向数据全是unknown模块ID不匹配/地址非相对地址/符号表缺失检查构建流程、地址转换逻辑、符号表文件采样一开就崩信号处理函数不安全/缓冲区溢出检查异步信号安全函数清单、缓冲区边界火焰图占比异常采样偏差/内联函数增加采样时长、对照反汇编确认内联调用栈断裂帧指针链不完整检查编译选项、回退到栈扫描上传数据量过大聚合不充分/采样频率过高优化哈希聚合、降低采样频率符号化速度慢符号表太大/查询算法低效预处理排序、二分查找、缓存结果5.5 几个实测有效的避坑技巧第一个技巧是采样开关做成动态的。不要一进游戏就开采样而是通过配置或者远程开关控制只在需要排查的版本或者需要排查的玩家群体上开启。这样既能控制开销又能精准采集。第二个技巧是给采样数据打上场景标签。上传的时候带上当前场景ID、设备型号、系统版本、游戏版本这样在服务端可以做多维度的聚合分析。比如你发现某个卡顿只在骁龙660上出现那排查方向就明确多了。第三个技巧是保留原始数据一段时间。聚合后的数据虽然小但丢失了细节。我一般会在客户端保留最近几次采样的原始数据如果服务端分析发现异常可以远程拉取原始数据做更细粒度的分析。第四个技巧是符号表版本管理要严格。每次出包都要归档对应的符号表并且用版本号或者构建号关联起来。我见过太多团队因为符号表丢失导致线上数据没法解析最后只能重新出包复现浪费大量时间。6. 从采样数据到性能优化决策6.1 怎么读火焰图拿到火焰图之后不要一上来就盯着最宽的那一块看。我的习惯是先看整体形状如果火焰图很“平”说明CPU时间分散在很多函数上这种情况往往是架构问题比如每帧都在做大量小对象的分配和释放如果火焰图很“尖”说明时间集中在少数几个函数上这种情况优化起来见效快。然后看调用链的深度。如果某个热点函数的调用链特别深说明中间经过了很多层封装可以考虑能不能把一些不必要的中间层去掉。如果调用链很浅说明这个函数就是实打实的计算密集得从算法或者数据结构上想办法。最后看跨帧的一致性。单帧的火焰图可能有偶然性我会连续采几十帧看热点函数是不是稳定出现。如果某个函数只在特定帧出现那可能跟特定的游戏事件相关比如技能释放、场景切换。6.2 一个真实的优化案例回到开头那个技能卡顿的案例。真机采样数据回来之后火焰图上清楚地显示技能释放的那一帧主线程有大约60%的时间花在FileRead相关的调用上调用链是SkillCast - LoadSkillAssets - AsyncLoad - FileRead。而在其他帧FileRead的占比不到5%。这就很明确了技能资源没有预加载导致释放瞬间同步读盘。优化方案也很直接在进入战斗场景时把所有技能的资源配置表读出来提前异步加载到内存池里。改完之后再采样技能释放帧的FileRead占比降到了3%以下线上卡顿投诉基本消失了。这个案例说明一个道理真机采样数据的价值不在于数据本身有多精确而在于它能把你带到正确的排查方向上。如果没有这份数据我们可能还在特效数量上打转。6.3 采样数据的长期价值单次采样解决单次问题但如果你把采样数据持续积累起来它的价值会指数级上升。比如你可以建立版本间的性能基线每个版本上线后对比核心场景的火焰图看有没有新的热点函数冒出来。你还可以做机型维度的对比同一份代码在不同芯片上的热点分布可能完全不同这能指导你做针对性的机型适配。我现在养成的习惯是每个大版本上线后一周拉一次全量采样数据做一次性能回归分析。这个习惯帮我提前发现过好几次性能劣化都是在玩家大规模投诉之前就修掉了。6.4 关于采样频率和精度的再思考最后再聊一个容易被忽视的点采样频率和精度的关系。很多人觉得采样频率越高越好但实际上对于“找热点”这个目标采样时长的价值远大于采样频率。1000Hz采10秒总共10000个样本比5000Hz采1秒的5000个样本更有统计意义。因为采样本质上是随机抽查样本越多统计结果越接近真实分布。所以我的建议是在设备扛得住的前提下优先保证采样时长其次才是提高频率。对于大多数卡顿问题1000Hz采30秒到60秒足够定位到热点函数了。真正需要高频采样的场景是抓那种毫秒级的尖峰卡顿那种情况才需要把频率拉到2000Hz以上同时配合更精细的触发条件。另外采样数据只能告诉你“哪里慢”不能告诉你“为什么慢”。要搞清楚为什么慢还得结合代码审查、反汇编分析、硬件性能计数器等手段。Profiler是望远镜不是显微镜它能帮你找到方向但最终的诊断还得靠你自己。