CSDN 版文章链接:待发布 知乎专栏版文章链接:待发布
点题、总览与边界:先把"掉帧"拆成两件事
先说说这一篇要干什么。 上一篇我们把"GPU 忙不忙"这条链路串通了,结论是这台机器的 GPU 大部分时间没在干活。可是链路串通之后,真正让人卡住的还是那一个具体问题:某一帧确确实实超期了,这个"掉"该记在谁头上? 是 jank(应用、调度、合成侧的时序问题),还是 shader 太重(GPU 执行、像素、带宽的成本)?
这一问比上一篇更尖锐。 上一篇只需要一个方向——GPU 忙还是 GPU 在等;这一篇要判的是两个嫌疑里该抓哪一个,而它们分住两层工具:jank 那半边住系统层的 FrameTimeline,shader 那半边住 API 层的 RenderDoc。要判这一问,就得把两层工具的路都走到头,再在走不通的地方如实停下。
三处最容易走偏的地方,先说在明处。 第一处是跳过责任方:看到一个超期帧就直奔着色器,可它可能是合成侧的锅——jank_type 里有唯一一个名字带 GPU 的条目,而它偏偏属于 SurfaceFlinger(合成器)。第二处是把"超期"当"归因":overrun > 0 只说明这一帧晚了,它不说明晚在哪;把两者混成一句话,后面的每一步都会问错问题。第三处是以为反汇编能"证明"shader 不重:本篇手上没有任何 GPU 周期标定,反汇编在这里的合法身份只有"排除性证据"——这句边界会在第五章的图注里再写一次。
本篇的边界画在"只讲这一处分叉"上。 本篇是本系列《图形性能剖析工具链》的第 12 篇,也是 PART C 自跑实战的第二篇:
- 不复述反汇编面板怎么用:Shader Viewer 长什么样、反汇编页签怎么打开、IR 与源码怎么映射,是 ep06 的内容;
- 不复述 FrameTimeline 的判据与归因推导:
overrun怎么算、两轨语义、8 个条目、三个分类函数,是 ep10 的内容; - 不复述"GPU 忙不忙"的判定:GPU 受限 / CPU 受限两组证据、
gpufreq与GPU Memory怎么读,是 ep11 的内容; - 本篇只做一件事:把"一帧超期了"这句症状,沿"责任方 → 系统层能问到哪 → 换腿取绘制量与指令量级 → 排除法 → 能判还是不能判"走到底,并在走不动的地方写明为什么。
还有一处边界要说在明处:本篇的交叉印证只到"分层 + 方向 + 量级自洽",做不到"同一帧的因果链"。 为什么做不到,7.2 节专门讲。这个口径从开篇钉到结尾,不美化。
0.1 三句抱怨,三种该先问的问题
先来看看真实工作里最常听到的三句抱怨。 它们的共同点是都能被数据回答;不同点是,回答它们要看的证据形态完全不一样,因此"该先问哪一步"也不一样。
| 抱怨原话 | 数据上是什么意思 | 该先问哪一步 |
|---|---|---|
| "一卡一卡的,帧率掉到 40 了" | 确有帧超期,但不知这帧记在谁头上 | 第 0 步:责任方分流 |
| "GPU 看着没吃满,帧就是上不去" | 超期帧落在 app 侧,而系统层给不出 GPU 时长 | 第 1 步:cpu_time 拆解 |
| "是不是我这个着色器写太重了" | app 侧超期 + 把"GPU 执行慢"当嫌疑 | 第 2、3 步:绘制量 + 反汇编排除 |
第一句最容易查,也最容易查错。 它的正确走法是先拿到 jank_type / jank_tag:如果归到合成侧,那翻应用的着色器从一开始就是走错门。
第二、三句才是这条链的价值所在。 第二句对应一个"数据看起来齐、结构上缺一块"的假象:cpu_time 把"CPU 算的"和"等 GPU 的"混在同一个数里(3.1 节),所以"cpu_time 很大"推不出"CPU 很忙"。第三句对应本篇唯一一腿新能力:反汇编的输出被当成排除性证据用。
三句并成一句,就是本篇的题眼: 掉帧归因不是一个结论,是一条顺序不能颠倒、并且在某一步必须停下的判别链;九章的顺序见下一节。
0.2 全篇知识地图
先来看看下面这张表——它是后面九章的顺序与产出。它不是并列的知识点,而是一条排查动线——顺序不能颠倒:先分流责任方才换腿,先有量级才谈得上排除。
| 章节 | 在回答什么 | 你会拿到什么 |
|---|---|---|
| 零、点题与边界 | 这一篇讲什么、不讲什么? | 知识地图 + 版本线 + 术语双语表 + 边界声明 |
| 一、判别链全貌 | 六步怎么走、在哪里停? | "超期 → 责任方 → 系统层 → 换腿 → 排除"的固定动作序列 |
| 二、第 0 步:责任方分流 | 该查应用还是查合成? | jank_type / jank_tag 真值 + "带 GPU 名字的条目在合成侧" |
| 三、第 1 步:系统层能问到哪 | 哪条线程吃了时间? | 四档严重度 + cpu_time 拆解 + 487 与 16 |
| 四、第 2 步:API 层取什么 | 不打开界面能取到哪三样? | 绘制量、管线状态、资源绑定真值 + "像素着色上界"算式 |
| 五、第 3 步:反汇编当证据用 | 指令清单能排除什么? | 四个调用坑 + VS 32 条 / PS 3 条原文 + 五条判据 |
| 六、排除法与结论落点 | 什么时候可以判、什么时候必须停? | 三档结论:可排除 / 不能排除 / 数据不足 |
| 七、边界与陷阱 | 哪里取不到、哪里会取错? | 陷阱矩阵 + "为什么同帧因果链在本机做不到" + "接口在、库不在"同构 |
| 八、收口清单与结语 | 下次遇到同类问题怎么办? | 症状 / 系统层 / API 层 / 印证点 / 边界 五列清单 |
0.3 版本演进线:本机 v1.29 与官方 v1.46 之间的十七个小版本
先说说口径:下表是一条简化的能力演进时间线,一律以本机可核的版本号或官方文档可查的版本为准,不是内部提交时间。本机所有实测都跑在这套版本上。
| 阶段 | 时间 / 版本 | 关键变化 | 对本篇的影响 |
|---|---|---|---|
| FrameTimeline 进系统 | Android 12(S) 起 | 官方页写明"FrameTimeline requires Android 12(S) or higher",SurfaceViews 暂不支持 | 第 0 步分流的前提门槛 |
| 反汇编的双目标 | 官方页写成"取决于 API、可用插件与硬件支持" | 下拉会出现 硬件相关 ISA(如 AMD GCN) | 本机实测下拉 = DXBC 与 AMD GCN ISA 两项 |
| RenderDoc 本机版本 | v1.29(build 0202a066a9ffa4077fa980da2d97ad5fb6ec5827) |
内嵌 Python 3.6,--python / --ui-python 双入口可用 |
第三、四、五章的实测环境 |
| RenderDoc 官方最新 | v1.46 | 内嵌 Python 升至 3.8 | 本机落后 17 个小版本 |
| Perfetto trace 处理器 | v58.2-add693d8b(Windows,RPC API v14) | FrameTimeline 与 GPU 表族齐备 | 第二、三章的实测环境 |
| Android 侧现抓 | RenderDoc 支持 Vulkan 与 OpenGL ES,设备至少 Android 6.0 且包必须已安装且可调试 | — | 本机 adb devices 为空,做不到"现抓一份真机帧" |
这张表里最该记住的是第三行。 本机 v1.29 不是"差不多够用"的旧版本:内嵌解释器是 3.6,而且它出厂不带任何 NVIDIA 的计数器插件——后者不是配置问题,是许可证问题(第七章给目录级反证)。
0.4 术语双语对照(先备着)
先来看看这套术语的双语对照。 后面几章会密集出现官方术语原名,中文侧译法并不统一,这里一次钉死:
| 中文 | English | 一句话 |
|---|---|---|
| 掉帧 / 卡顿 | jank | 一帧没能按时上屏,或呈现晚了 |
| 责任方 | blame | 这次超期该记在谁头上:应用、合成器、显示链路还是预测漂移 |
| 超期 | overrun | actual 终点减 expected 终点,为正才算超期 |
| 反汇编 | disassembly | 把着色器字节码还原成指令清单,输出是文本 |
| 排除性证据 | exclusionary evidence | 只能排除某个假设,不能测量耗时 |
| 帧预算 | frame budget | 1000 ÷ 目标 FPS,60 FPS 是 16.67 ms |
| 像素着色上界 | pixel shading upper bound | 用覆盖像素数与采样次数算出的上限,不是实测 |
| 构造性演示 | constructive demo | 人为搭的场景,只演示"怎么判",不代表真实项目 |
一句话记住本篇的方法:先分流责任方,再看系统层问到哪;量级对不上就把 shader 排除掉,连数都拿不到就如实说数据不足。
0.5 边界声明与量化锚点
边界在开篇说一次,第七章再确认一次:
- 不复述 API 层面板操作(抓帧、回放、纹理查看、反汇编面板与 IR 读法)→ 属 ep01 到 ep06;
- 不复述系统层判据(protobuf 结构 / data source / SQL 调优 / FrameTimeline 判据与归因推导)→ 属 ep07 到 ep10;本篇只用 ep10 的结论当输入;
- 不复述 GPU 忙闲判定(
gpufreq/GPU Memory/ 桌面帧内开销)→ 属 ep11; - 不做同帧印证:印证口径是 "分层 + 方向 + 量级自洽",理由见 7.2 节;
- 不做 Android 真机现抓:本机
adb devices为空,六份 trace 全是官方测试数据; - 不测 shader 耗时:
FetchCounters全 0、plugins\下无nv,没有任何 GPU 周期标定,反汇编只作排除性证据; - 不把构造性演示当真实结论:第五、六章展示的是"怎么判断",不是"某个真实项目的 shader 不重"。
本篇要拿下的可核对数字有六个,全部来自本轮实测或官方原文:官方"只分 app / SF、不进应用内部找原因"的自陈;jank_type GLOB '*GPU*' 六份 trace 恒为 0 行;487 个正超期帧里只有 16 个(3.3%)能挂上线程;最极端单帧 1,884,166,196 ns(1884 ms);反汇编 VS 32 条 / PS 3 条、无循环无动态分支;绘制量 2 个 draw / 294 个索引 / RT 1264×681。
先看看本篇与前三篇的题域切分图——它决定了后面每一章能讲什么:
这张图上最窄的那一条就是本篇。 它窄到只有一个问题,但这个问题必须在两套工具都走到头之后才能回答。
一、判别链的全貌:六步与一个必须停下的地方
下面我们开始拆这条链。 这一章不给结论,只做一件事:把"这帧超期了,到底是 jank 还是 shader"这件事,切成六个有先后、有输入输出、有失败信号的步骤。后面每一章都是这六步里某一步的展开。
1.1 六步判别链:输入、输出与走不通的信号
先看看六步各自的输入、输出,以及"走不通时是什么样":
| 步 | 动作 | 输入 | 输出 | 走不通的信号 |
|---|---|---|---|---|
| 1 | 确认超期 | 一份带 FrameTimeline 的 trace | overrun > 0 的帧集合 |
没有 FrameTimeline,或 android_frames 零行 |
| 2 | 分流责任方 | 超期帧的 jank_type / jank_tag |
app / SF / HAL / 预测漂移 / 未定 | jank_type = None 却 overrun > 0 |
| 3 | 问系统层 | app 侧超期帧 | 四档严重度 + cpu_time 与它的三个分量 |
超期帧挂不上任何线程切片 |
| 4 | 换腿取数 | 一份可回放的抓帧 | 绘制量、管线状态、资源绑定 | 抓帧与 trace 不是同一设备同一帧 |
| 5 | 排除法 | 指令清单 + 绘制量 | 可排除 / 不能排除 | 指令里出现循环或动态分支 |
| 6 | 结论落点 | 前五步的输出 | 三档之一:可排除 / 不能排除 / 数据不足 | 缺 GPU 时长且缺线程切片,两者都缺 |
这张表里最容易被跳过的是第 2 步。 多数人的实际流程是"看到卡 → 打开 RenderDoc → 翻着色器",这条路在合成侧出问题时方向就是反的。本篇第二章会用一个本机真值说明:那个出口对应的条目,在六份 trace 里一次都没出现过。
1.2 判别链总图:每一步在哪里停
把这六步画出来,就是本篇的主线图。图上要显式标注两件事:哪些步骤能给出结论,以及印证只到"方向 + 量级"。
图上那道虚线的位置就是本篇的诚实边界。 它画在"系统层 → 换腿"这一步上:不是想换工具,是系统层到这里就问不下去(3.1 节给官方依据)。虚线右边两条腿各自站得住,它们之间的连接是方向级的,不是等值级的。
1.3 官方怎么画一条"链路"
顺带看一眼官方对"链路"的图示形态——Perfetto 官方文档里有一张 flow 箭头的小图,这是官方对"一条链路怎么连起来"的既有图示,与本篇自绘的判别链互为对照:
先来看看箭头代表什么。 官方 flow 箭头连的是同一份 trace 里两个切片之间的等值关系;本篇判别链的箭头连的是两个工具产出之间"方向是否一致"。看清这一点,后面每句话都不会被过度解读。
1.4 印证口径先声明:分层 + 方向 + 量级自洽
这一点必须在链路全貌里先钉住,否则后面每一章的结论都会被读成比它实际更强的结论。
理想形态是"同一帧因果链":一份数据里同时含系统层的帧切片与 API 层的提交记录,用帧号或时间戳把它们连起来,于是"这一帧超期,是因为这一帧里第 22 个 draw 的着色器太重"是一条等值关系。这个形态在本机做不到,而且不能靠配置调好——第七章 7.2 节给三条实测与一条官方自陈。
本篇退到第二档,判据只有一行:
印证:方向(系统层看到的"谁超时 / 哪条线程吃时间")
== 方向(API 层看到的"绘制量 × 指令成本上界"的量级)
其中:
- 左侧取值:超期帧落在 app 侧还是 SF 侧;`cpu_time` 与帧预算的位置关系
- 右侧取值:绘制量(draw 数 / 索引数 / 目标尺寸)与指令量级(条数 / 有无循环 / 采样次数)
- 两侧指向同一个方向 → "量级自洽",结论可按第六章三档落地
- 两侧任一侧缺数据 → 直接进"数据不足",**不许用另一侧单独下结论**
最要紧的是"量级自洽"四个字,而不是"数值相等"。 系统层看到的是"这条线程吃掉了 N 毫秒",API 层看到的是"这一帧只有 2 个 draw、294 个索引、像素着色 3 条指令"——这两个数在量纲上不可比,只可比"它们指向的方向是否冲突"。想看数值相等,先得标定每指令周期数,而这件事本机做不到。
1.5 落成一条可跑的命令链
好了,概念讲完,落到一次真实动作上。下面这段是本篇的固定动作序,每一步产出一个文件,后面每一章的查询都读这些文件。它是本章的落地动作,不是判据:
#-----------------------------------【判别链六步:四条腿一次跑完】------------------------------------------
# 描述:左腿(系统层)从 trace 取责任方与线程时间,右腿(API 层)从 .rdc 取绘制量与反汇编
# 实测:本机 RenderDoc v1.29(C:\Program Files\RenderDoc\)+ trace_processor_shell v58.2-add693d8b
#--------------------------------------------------------------------------------------------------------
#【1】第 1–2 步·系统层:确认超期 + 归因(jank_type 与 jank_tag 一起取)
trace_processor_shell.exe query <trace> "SELECT jank_type, jank_tag, count() FROM actual_frame_timeline_slice GROUP BY 1,2 ORDER BY 3 DESC"
#【2】第 3 步·系统层:四档严重度与 cpu_time 的三个分量(SQL 见 3.8 节)
trace_processor_shell.exe query <trace> "<SQL 见 3.8 节>"
#【3】第 4 步·API 层:不打开界面,先把管线状态与绘制量抽成 JSON
Start-Process 'C:\Program Files\RenderDoc\qrenderdoc.exe' -ArgumentList '--python','ep12_pipe_probe2.py'
#【4】第 4 步·API 层:脚本跑完进程不会自己退出,必须手动收尾
Stop-Process -Name qrenderdoc -ErrorAction SilentlyContinue
#【5】第 5 步·API 层:取反汇编(先问目标列表,再取文本;四个坑见 5.1 节)
Start-Process 'C:\Program Files\RenderDoc\qrenderdoc.exe' -ArgumentList '--python','ep12_shader_probe4.py'
#【6】第 5 步·API 层:同样需要收尾——本轮四次实跑,每次都要 Stop-Process
Stop-Process -Name qrenderdoc -ErrorAction SilentlyContinue
下面对【3】【6】特别说明。【3】走的是 --python 入口:脚本跑在 UI 起来之前,产物是纯 JSON,能直接进脚本二次统计,代价是它跑完不会自己退出——本轮四次实跑,每次都靠 Stop-Process 收尾,模板里不写这一行,下一次就会卡在"为什么我的批处理不结束"。【6】是同一件事的第二次出现——入口设计的性质,不是偶然踩到。 反汇编那一腿的四个坑更隐蔽,其中三个不报错,5.1 节逐条给。
1.6 本章收在哪里
第一章走完,手里该有三件事:一条顺序不能颠倒的六步判别链、每一步的走不通信号、印证只到"分层 + 方向 + 量级自洽"。下一章先把第 2 步做扎实。
本章对应的官方依据:FrameTimeline 的设计目标与
max(gpu, post)见 FrameTimeline 数据源页;flow 箭头形态见 Perfetto 事件轨道文档;反汇编入口的签名见 RenderDoc Python API 的 replay 页。
二、第 0 步:这帧该查应用,还是查合成
下面我们开始走第 1 步与第 2 步。 这两步合起来只回答一个问题:这一帧超期了,它该记在应用的头上,还是记在合成器(SurfaceFlinger)的头上? 把责任方(blame)分错,后面所有深挖都会敲错门——把合成侧的迟到记到应用的着色器上,是最昂贵的一种走错。
2.1 分流的原料:8 个条目、5 个标签、3 个分类函数
先来看看分流的原料。官方 FrameTimeline 页给出的是一张 8 个条目的清单,其中一个是 None(无 jank),所以真正算 jank 的是 7 条。把它们并列成"8 类 jank"是常见口误;而 None 不该进"卡顿帧分类统计"的分母。这条清单怎么读、三个分类函数怎么把字符串归到责任方,ep10 第四章已经讲透,本篇不复述。这里只做一件事:把它的出口接到判别链上。
jank_type 与 jank_tag 经三个分类函数收敛成 app / SF / HAL / 预测漂移 / 未定五个出口;SurfaceFlinger GPU Deadline Missed 是唯一名字带 GPU 的一类,而它在合成侧@ 本系列绘制这张图上最该盯住的是那个带 GPU 字样的方框。 它叫 SurfaceFlinger GPU Deadline Missed,判据是"CPU 时间没超,但 GPU 没按时完成,于是被推到下一个 vsync"——它是全部条目里唯一提到 GPU 的,而它属于合成侧。→ 当一帧因为 GPU 而迟到时,FrameTimeline 会把它记在合成器名下,而不是记在应用的着色器名下。这一条决定了本篇主线的前半步。
| 出口 | 由哪些条目进入 | 下一步该做什么 |
|---|---|---|
| 应用侧 | App Deadline Missed 一类 |
进第 3 步,问"哪条线程吃时间" |
| 合成侧 | SurfaceFlinger CPU / GPU Deadline Missed、Scheduling 一类 |
查合成与显示链路,不要去翻应用的着色器 |
| 显示链路 | Display HAL |
查硬件抽象层与上屏时序 |
| 预测漂移 | Prediction Error |
调度器会自我纠正,孤立漂移几乎感知不到,单列不当锅 |
| 未定 / 不是问题 | None、Buffer Stuffing(官方定义为"更像一种状态,不是一次 jank") |
记录,不进"谁错了"的结论 |
这张表里第三、四行最容易被忽略。 分流不只是"分给谁",还包括"这次超期算不算需要人管的事"——Prediction Error 与 Buffer Stuffing 都属于"帧数看着没问题、手感变差"的形态,把它们当成应用侧的锅去优化,等于给一个不存在的 bug 写补丁。
2.2 真值:六份 trace 的归因分布
先来看看真值:分流要落地就得有数字。本机在六份官方 Android 测试 trace 上逐份跑了 jank_type 与 jank_tag 的分布:
jank_type 与 jank_tag 真值分布:api31_startup_warm 是唯一"合成侧类型齐全"的样本(SurfaceFlinger Stuffing 4 / Scheduling 2 / Display HAL 2 / CPU Deadline Missed 1 / Prediction Error 5)@ 本机自跑(Perfetto trace_processor v58.2 + 官方测试 trace)| trace | jank_type 分布要点 |
|---|---|
android_startup_real.perfetto_trace |
None 1921、Buffer Stuffing 89、App Deadline Missed 12、SurfaceFlinger Scheduling 3、Dropped Frame 2、多值 App Deadline Missed, Buffer Stuffing 1 |
webview_jank.pb |
None 980、Buffer Stuffing 32、App Deadline Missed 16、多值 3 |
api31_startup_warm.perfetto-trace |
None 97、Prediction Error 5、SurfaceFlinger Stuffing 4、SurfaceFlinger Scheduling 2、Display HAL 2、SurfaceFlinger CPU Deadline Missed 1、Buffer Stuffing 1、App Deadline Missed 1 |
wattson_jank_cuj.pb / api34_startup_cold / api32_startup_warm |
三份都只有 None 与少量 App Deadline Missed 一类 |
这张表有两件事要说清。 第一,api31_startup_warm 是本篇最有价值的样本:它同时出现合成侧四类与预测漂移,是"分流"这一节唯一能把多个出口同时点亮的真数据(ep10 只用了最大样本与 wattson)。第二,那份样本里出现了 SurfaceFlinger Stuffing,它不在官方页列的 8 个条目里——所以本篇对 jank_type 一律表述为"官方页列 8 个条目,真数据另有清单外取值",不写"只有 8 类"。
另一列 jank_tag 是本篇新用的(ep10 未用),它比 jank_type 更直接地回答"该不该去查应用":
| trace | jank_tag 真值 |
|---|---|
android_startup_real |
No Jank 1921 | Buffer Stuffing 89 | Self Jank 13 | Other Jank 3 | Dropped Frame 2 |
webview_jank.pb |
No Jank 980 | Buffer Stuffing 32 | Self Jank 19 |
api31_startup_warm |
No Jank 97 | Self Jank 6 | Other Jank 5 | SurfaceFlinger Stuffing 4 | Buffer Stuffing 1 |
Self Jank 是本进程主因、Other Jank 是别处主因——这一列官方页没有逐字定义(本轮复核仍未取到),只能按交叉形态读:六份 trace 上 App Deadline Missed 一律落 Self Jank、SurfaceFlinger Scheduling 一律落 Other Jank,但 Prediction Error 与 Display HAL 两侧都出现过(api31 上 3:2 与 1:1),故它只是倾向,不是 app / SF 的二值判据。
2.3 官方实景:在 UI 上选中一个合成侧切片
先来看看分流在界面上的终点:选中一个合成侧的 slice 看它的详情——这是官方给的实景:
这张图提醒一件事:分流是可以用鼠标完成的。 点一下切片、看一眼详情面板,结论与 2.1 节那三个分类函数一致;本篇走命令行只是为了让结论可复现、可进脚本。
2.4 真值:GPU 那一侧,它有名字,但没有数据
先来看看这一节:它是第二章最要紧的一节,也是"为什么必须换腿"的第一个理由。
jank_type GLOB '*GPU*' 六份全部 0 行,args 里检索 GPU Deadline Missed 也是 0 行@ 本机自跑(Perfetto trace_processor v58.2 + 官方测试 trace)-------------------------------------【GPU 归因:它有名字,但没有数据】-----------------------------------
-- 描述:在 jank_type 里检索含 GPU 字样的条目,再在 args 全表里检索关键词
-- 实测:六份 trace 上两问全部为 0 行;真值分布里从未出现 SurfaceFlinger GPU Deadline Missed
--------------------------------------------------------------------------------------------------------
SELECT count() AS gpu_jank_rows FROM actual_frame_timeline_slice WHERE jank_type GLOB '*GPU*'; --【1】
SELECT count() AS n FROM args WHERE display_value GLOB '*GPU Deadline Missed*'
OR key GLOB '*GPU Deadline Missed*'; --【2】
下面对【1】【2】特别说明。【1】用 GLOB 而不是等值比较,因为 jank_type 可以是逗号拼接的多值形态(ep10 第四章给过实例),等值比较会漏。【2】换一个入口再问一次:args 是参数全表,连键名带值一起搜,两问在六份 trace 上都是 0 行。→ 结论有两层:一,SurfaceFlinger GPU Deadline Missed 在真值分布里一次也没出现;二,就算它出现了也属于合成侧(2.1 节)。"它有名字但没数据,而那个名字不归应用管"——这是必须换腿的第一个理由。
它为什么零出现,本轮查到官方条件与一条反证。 官方定义为「CPU 时间没超,但 GPU 合成没按时完成,于是被推到下一个 vsync」;六份 trace 里走过 GPU 合成的帧行共 295 个(189 / 49 / 56 / 1),全部是 None——合成侧确实上过 GPU,只是没有一帧迟到。→ 原因是"GPU 合成的帧都按期完成",不是"这条路没被走到"。
2.5 本章收在哪里
第二章走完,第 0 步该有能力了:五出口的分流表("合成侧"与"显示链路"两个出口直接把应用侧排除掉)、六份 trace 的归因真值、jank_tag 只给倾向,不是二值判据、GPU 归因恒为 0。下一章走进应用侧的出口:哪条线程吃了时间。
本章对应的官方依据:8 个条目与设计目标见 FrameTimeline 数据源页;分类函数与
jank_type的 GLOB 判据见 jank_type.sql 源码;列级条目见 Perfetto SQL 表参考。
三、第 1 步:系统层能问到哪,以及它为什么必须换腿
下面我们开始第 3 步。 这一步仍然不打开抓帧文件,它问的是:这帧超期了,时间花在哪条线程上? 而这一章真正的重点是后半句——问到这里就到底了,再往下问不出来。为什么问不出来,这一章给官方结构与官方自陈两条依据。
3.1 四档严重度与 cpu_time 的拆解:为什么这个数里躺着两个东西
先来看看系统层能给的东西。官方标准库在 android.frames.per_frame_metrics 里给了四档严重度,第一档判的是 overrun(与截止时间比),后三档判的是 cpu_time(CPU 耗时)——两把不同的尺子,不能混着读成"四档超期"(ep10 第三章已讲透,本篇只用它定"多严重"):
| 档 | 判据(源码口径) | 一句话 |
|---|---|---|
was_jank |
overrun > 0 |
错过了截止时间 |
was_slow_frame |
cpu_time > 20 ms |
这一帧的 CPU 时间超过 20 毫秒 |
was_big_jank |
cpu_time > 50 ms |
超过 50 毫秒 |
was_huge_jank |
cpu_time > 200 ms |
超过 200 毫秒 |
关键在最后一列说的那个 cpu_time 到底装了什么。 官方把一帧的 CPU 侧时间拆成几块:app_vsync_delay(等 vsync 的时间)、ui_time(UI 线程)、draw_frame_dur(渲染线程里所有 DrawFrame 的时长之和),cpu_time 是它们的合成。而 cpu_time 之所以不能直接读成"CPU 很忙",是因为它里面同时躺着"CPU 在算"和"CPU 在等 GPU"两件事:
cpu_time 的拆解与它为什么不可直接读:等待 vsync、UI 线程、渲染线程三段合成 cpu_time,而 DrawFrame 里"等 GPU"与"CPU 算"混在一起;官方把 actual 的终点定义为 max(gpu, post),结构上就不给 GPU 的分解@ 本系列绘制官方自己把这件事说透了,两处。 第一处是结构:actual 轨的终点,官方原文写的是「The end time of the slices here represent max(gpu time, post time)」,并补一句「Since the GPU usually runs in parallel, it could be that the gpu finished later than the post time.」——一帧的"实际时长"里含 GPU 工作,但不给 GPU 的分解。第二处是明说:Android 官方讲 Android 11 的四条轨时,对"Wait for GPU"那条轨加了一句限定——「这并不表示 GPU 在此期间仅使用相应缓冲区的内容工作。」并指向 Android GPU 检查器。
这两处合起来,就是"必须换腿"的官方依据:系统层只说"谁超时、哪条线程吃时间",不管"这段时间是 CPU 算的还是在等 GPU"。 想分这两件事只有一条路——去 API 层看这一帧提交了什么、指令重不重。换腿不是临时起意,是官方在文档里把读者推过去的。
还有一处必须当场算的换算:帧时间限制不是一个常数。 本篇一律现算:帧预算(frame budget) = 1000 ÷ 目标 FPS。桌面 60 FPS 是 16.67 ms,120 Hz 是 8.33 ms;而 Android 官方那页给的另一个例子是 25 毫秒,语境是移动游戏、约 40 FPS(1000 ÷ 40 = 25);同一页还有 33.3 毫秒,那是 30 FPS。这三个数不是三个竞争的标准,是同一个公式在三档目标帧率下的取值——把 25 ms 当桌面 60 FPS 的红线会漏掉大量超预算帧,把 16.67 ms 拿去当移动游戏合格线会把正常帧判成掉帧。
3.2 真值:超期的正负与两个极值
先来看看真值。本机六份 trace 的 android_frames_overrun 上,overrun 的分正负统计与极值如下:
overrun 真值:正超期合计 487 帧(236 / 42 / 149 / 24 / 33 / 3),极值 +1,884,166,196 ns(1884 ms) 与 +637,885,146 ns(637.9 ms),min 六份全为负(−13.9 至 −20.5 ms)@ 本机自跑(Perfetto trace_processor v58.2 + 官方测试 trace)| trace | 行数 | 正超期 | max(ns) |
|---|---|---|---|
android_startup_real |
1395 | 236 | +1,884,166,196 |
webview_jank.pb |
774 | 149 | +637,885,146 |
wattson_jank_cuj.pb |
215 | 42 | +2,078,702 |
api31_startup_warm |
110 | 33 | +56,384,379 |
api34_startup_cold |
202 | 24 | +29,202,520 |
api32_startup_warm |
16 | 3 | +197,270 |
| 合计 | — | 487 | — |
这张表要读出两件事。 第一,负 overrun 是常态不是异常:六份 trace 的 min 全在 −13.9 到 −20.5 ms 之间,那是"提前完成",用"绝对值大于某阈值"筛异常会把它们全部误判。第二,极值 1884 ms 的那一帧不是帧时间问题——它相当于 60 FPS 帧预算的 113 倍,任何"着色器太重"的解释在这个量级上都不成立。这一条会在第六章成为"数据不足"的实例。
3.3 真值:487 个超期帧里,只有 16 个能挂上线程
先来看看这一节,它是第三章最重要的数字,也是"数据不足"这条出口的凭据。
Choreographer#doFrame / DrawFrame 切片(逐份 236→12 / 42→1 / 149→0 / 24→2 / 33→0 / 3→1)——"数据不足以判定"的硬数字@ 本机自跑(Perfetto trace_processor v58.2 + 官方测试 trace)| trace | 正超期 | 能挂上 android_frames 的 |
覆盖率 |
|---|---|---|---|
android_startup_real |
236 | 12 | 5.1% |
wattson_jank_cuj.pb |
42 | 1 | 2.4% |
webview_jank.pb |
149 | 0 | 0% |
api34_startup_cold |
24 | 2 | 8.3% |
api31_startup_warm |
33 | 0 | 0% |
api32_startup_warm |
3 | 1 | 33.3% |
| 合计 | 487 | 16 | 3.3% |
这张表改变了这一章的写法。 96.7% 的超期帧在 trace 里既找不到 Choreographer#doFrame 也找不到 DrawFrame——它们连"哪条线程的锅"都问不出来,所以第三章只能写成"系统层有时能告诉你,多数时候不能"。能挂上的那 16 帧倒很整齐:render_thread_utid 一律指向 RenderThread,ui_thread_utid 落在各应用主线程上(com.android.vending 4 / com.google.android.gm 2 / com.google.android.apps.photos 2 / com.android.systemui 2 / googlequicksearchbox:search 1 / nexuslauncher 1)——"应用侧超期"的落点几乎总在渲染线程这一侧。
为什么 96.7% 挂不上,本轮按 frame_id 逐行查过了。 这批帧的 frame_id 100% 出现在 actual_frame_timeline_slice 里(最大样本 236/236)→ 不是"键对不上";缺的是应用侧的 Choreographer#doFrame + DrawFrame 切片。471 个挂不上的帧按上报进程归堆:450 个带 surfaceflinger 上报行(逐份 222/224、33/33、2/2、22/22、41/41、130/149),只有 21 个纯应用侧;而挂得上的 16 帧无一带 surfaceflinger 行。→ 合成进程自己的帧本就不该有应用的帧切片,是结构使然,不是数据丢失。 android_frames_layers 也由 doFrame ⋈ DrawFrame 内连接构成,同样不含这批帧,不能当兜底。引用 3.3% 只表述为"在本机这 6 份 trace 上",不外推。
3.4 真值:四档会少报,而"超期"不等于"有归因"
overrun > 0 有 236 帧而 android_frame_stats 的 was_jank 只有 13(webview_jank.pb 上 149 → 0);另一份的 12 个可归因超期帧里有 1 帧 jank_type = None、2 帧是 Buffer Stuffing@ 本机自跑(Perfetto trace_processor v58.2 + 官方测试 trace)这张图上有两个独立的事实,要分开读。 第一个是少报:was_jank 所在视图要做帧级 CPU 时间聚合、依赖应用侧切片,切片缺失时那一帧即使超期也进不了统计——所以数掉帧要用 android_frames_overrun WHERE overrun > 0,不要用 was_jank(ep10 第七章已列过,本轮再复现:236 → 13、149 → 0)。口径差不止一处,单一视图当不了判据。
第二个是超期与归因不是同一件事:最大样本上 overrun > 0 且能挂到切片的 12 帧里,有 1 帧 jank_type = None、2 帧是 Buffer Stuffing(官方定义为"更像一种状态,不是一次 jank")。→ overrun > 0 只是"超期",jank_type / jank_tag 才是"归因";二者不一致时以归因为准并记录不一致本身。 同一样本还有一处极端情形:单帧 1884 ms 的那一帧,同一个 frame_id 在 4 个 layer 上给出 3 种 jank_type(Buffer Stuffing / None / None)——"一帧一个归因"是错觉。
3.5 旁证一:CPU 是不是在冲刺
问完"哪条线程",还有一路信号值得并排看:CPU 频率。"CPU 忙不忙"在系统层有直接的观测量,官方给的是这样一条轨道:
它在第三章的位置很直接:CPU 频率是"CPU 受限"这一侧最直接的旁证——超期的地方 CPU 频率并没起来,"CPU 算不过来"就先弱了一半。它与 ep11 用过的 gpufreq 是一对,本篇只用它们判方向,不重复 ep11 的展开。
3.6 旁证二:CPU 明明不忙,却掉帧
光看"忙不忙"是不够的,还有一种真凶是"不忙但没被调度上"。 官方给的第二个形态是意外休眠:
这张图给第三章的结论加一条保险。 在"该查应用"这个出口底下,凶手未必是"应用算得慢"或"GPU 执行慢",还可能是线程压根没被及时唤醒。所以换腿去翻着色器,翻的只是"GPU 执行成本"这一个假设。
3.7 旁证三:既不是归因错,也不是 shader 重
第三种成因与渲染无关,是别的进程在抢资源:
三张图连起来看,第三章的形状就出来了:系统层能给"哪条线程吃时间"(有时给不了)、"CPU 在不在冲刺"、"有没有别人在抢"三样;给不了"GPU 干了多久"与"这段时间里有多少是 CPU 在等 GPU"。 后两样正是换腿的理由。
3.8 把这一章落成一段查询
好了,下面是本章的落地动作:把四档严重度、cpu_time 的分量与"哪条线程"一次问出来。
-------------------------------------【系统层四问:严重度 / cpu_time / 线程 / 联结率】---------------------
-- 描述:先定严重度,再看 cpu_time 与它的分量,最后问"超期帧能不能挂上线程"
-- 实测:android_startup_real.perfetto_trace 上 overrun>0 有 236 帧,android_frame_stats 的 was_jank 只有 13
-- 依据:stdlib/android/frames/per_frame_metrics.sql
----------------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE android.frames.timeline; --【1】先引入帧归属
INCLUDE PERFETTO MODULE android.frames.per_frame_metrics; --【2】四档严重度与 cpu_time 都在这里
SELECT count(*) AS frames,
sum(iif(overrun > 0, 1, 0)) AS overrun_positive, --【3】这才是"掉了多少帧"
sum(CASE WHEN was_jank = 1 THEN 1 ELSE 0 END) AS was_jank_cnt, --【4】它会少报,只作对照
max(cpu_time) / 1000000 AS max_cpu_ms --【5】cpu_time 里含"等 GPU"
FROM android_frame_stats;
SELECT (SELECT count(*) FROM android_frames_overrun WHERE overrun > 0) AS pos_total,
(SELECT count(*) FROM android_frames_overrun o
JOIN android_frames f ON f.frame_id = o.frame_id WHERE o.overrun > 0) AS pos_joined --【6】
FROM (SELECT 1); -- 本机实测 236 → 12
下面对【3】【5】【6】特别说明。【3】是本篇反复强调的那一行:数掉帧用 overrun > 0,不用 was_jank,两者语义上是一件事、真数据上差一个数量级。【5】是理论支点落到列上:cpu_time 同时含 CPU 计算与等 GPU 的时间,所以它大不等于 CPU 忙。【6】是"数据不足"的量化入口——本机六份 trace 上它取值 16,分母 487。
3.9 本章收在哪里
第三章走完,第 3 步的边界清楚多了:四档严重度与 cpu_time 拆解、overrun 的正负与极值、487 与 16 这组"问到哪就到哪"的硬数字、少报与不自洽两类口径坑、三路旁证。系统层到这里就走完了,下一章换腿。
本章对应的官方依据:四档严重度与
cpu_time算法见 per_frame_metrics.sql 源码;max(gpu, post)与"只分 app / SF"见 FrameTimeline 数据源页;"Wait for GPU 不等于 GPU 干了什么"见 Android 官方 jank 检测页(镜像站);CPU 频率轨见 Perfetto CPU 频率文档;查询范式见 Android 轨迹分析入门。
四、第 2 步:换腿之后,API 层能取到哪三样
下面我们开始换腿。 这一章的立场和 ep11 一致:命令行优先——能用命令拿的就别开界面,因为命令行产物能进脚本、能存档、能二次统计。本章取三样:三条取数路径的分工、这一帧的绘制量、渲染目标与它的物证。
4.1 三条路径:输入、产物与能力边界
先来看看换腿之后的三个入口。它们都是 RenderDoc 自带的,不需要写插件:
renderdoccmd convert 取绘制量与帧内 API 耗时,qrenderdoc --python 管线探针取输出目标与绑定,DisassembleShader 取指令清单——第三条是本篇新增的那一腿@ 本系列绘制| 入口 | 输入 | 产物 | 能回答什么 | 拿不到什么 |
|---|---|---|---|---|
renderdoccmd convert -c chrome.json |
一份 .rdc |
帧内事件序列(B/E 配对) | 这一帧提交了多少事、API 耗时排前几的是谁 | GPU 内部计数、着色器耗时(ep11 已展开,本篇只回指) |
qrenderdoc --python 管线探针 |
一份 .rdc + 一段脚本 |
输出目标 / 顶点输入 / VB·IB / CBuffer 的结构化 JSON | 这一帧往哪画、画了多少、绑了什么 | 任何以"毫秒"为单位的 GPU 时间 |
DisassembleShader() |
同一份探针 + 反射对象 | 指令清单文本 | 这段 shader 编译成了什么 | 这段指令在 GPU 上跑了多久 |
这张表最该记住的是最后一列,也就是本篇的口径:第三条路给的是"指令长什么样",不是"指令跑了多久"。 看到"32 条指令、没有循环"就说"这个 shader 很快"是没有依据的——"快"需要时间量,而这条路上没有任何时间量。
4.2 绘制量:把"像素着色上界"算出来
先来看看这一帧的规模。管线探针的产物里,与"量级"最相关的是这么几个数:
DrawIndexed 各 numIndices = 6 与 288,合计 294 索引)、拓扑 TriangleList、RT ResourceId::35 = 1264×681 B8G8R8A8_UNORM、深度 ResourceId::46 = D24S8、VB stride 44 B(4 个属性偏移 0 / 12 / 24 / 32)、cb0 144 B(前 12 个 float 与单位阵一致)@ 本机自跑(qrenderdoc --python,v1.29)| 项 | 真值 | 对"量级"的意义 |
|---|---|---|
| 根动作 / 绘制 | 5 / 2 | 这一帧只有两个绘制 |
| 索引数 | 6 + 288 = 294 | 三角形数 = 294 ÷ 3 = 98 |
| 拓扑 | Topology.TriangleList |
索引与三角形是 3:1 的固定关系 |
| 渲染目标 | 1264×681 | 覆盖像素 = 860,784 |
| 深度目标 | 1264×681 D24S8 |
像素可能被丢弃 |
| VB stride | 44 B(偏移 0/12/24/32) | 顶点缓冲紧凑,44 才自洽(12+12+8+12) |
| cb0 大小 | 144 B(cb0[9]) |
每 draw 上传极小 |
像素着色上界(pixel shading upper bound)的算式可以直接写出来,这也是本章要拿下的第二个可核对数字:
像素着色上界 = 覆盖像素数 × 每像素采样次数
= (1264 × 681) × 1
≈ 860,784 次纹理采样
其中:
- 1264 × 681 是渲染目标尺寸,也就是"最多有多少像素参与了本帧的着色"
- "× 1" 来自像素着色器里那一次 sample 指令(见 5.3 节)
- 之所以叫"上界":本机没有硬件计数器,覆盖像素数只是一个上限,
真实被着色的像素可能因深度测试与遮挡而更少
这个式子要说清它是什么、不是什么。 它是上界,不是实测:能支撑"量级差得太远"这类判断,不能支撑"这一帧的纹理带宽是 X 字节"。没有硬件计数器时,把它当实测值就是拿上界冒充测量。
这里还有两处口径要交代。 第一,draw 数在两个口径下不一致:renderdoccmd convert -c chrome.json 只导出 1 次 DrawIndexed,而 Python 的 ActionFlags.Drawcall 全集是 2 个(事件 22 的 6 个索引 + 事件 30 的 288 个索引)——本轮逐行对上了:导出的事件流里根本没有事件 30 那一行(末尾是 Map → VSSetConstantBuffers → IASetIndexBuffer → Begin → End)。引用 draw 数一律标明取数口径。第二,VB stride 是 44 不是 40:四个顶点属性偏移 0/12/24/32 只有 44 自洽,正文与图注一律用 44,并把 ep11 的 40 记为旧值。
4.3 输出目标与它的物证
先来看看数字之外的物证:深度缓冲本身。它回答的是"那个 86 万像素的上界,作用在什么上面":
B8G8R8A8_UNORM(颜色)、1264×681 D24S8(深度)、2×2 B8G8R8A8_SRGB(小查找纹理),深度图正是"像素着色上界"里那个 1264×681 的物证@ 本机自跑(qrenderdoc --python,v1.29;底图为本机抓帧的深度导出,已加规格表与中文标注)为什么要给这张图。 因为 4.2 节算式里的 1264×681 是从 JSON 里读出来的一个数,它可以被"画面"复核:深度导出的像素尺寸与渲染目标一致,说明"覆盖像素 860,784"不是算错来的。这类底图不算证据,但它是"我们确实抓到了这一帧"的凭据——抓在帧外的话,后面的统计会全是 0 个 draw、0 微秒,而你可能会认真解释这个 0。
一条去重纪律。 同目录下还有一张后备缓冲导出图,它与 ep11 已用过的帧缩略图逐像素完全相同(平均差 0.0、最大差 0)——md5 不同、像素相同,属同一张图,本轮不启用;同理,遮挡实验与桌面采样的那几张属 ep11 题域,本篇也不占位。"文件名不同不等于没用过",这条本系列已经踩过三次。
4.4 把这一章落成一条命令
下面是本章的落地动作:从一份 .rdc 出发,一次把管线状态、绘制量与导出底图取出来。
#-----------------------------------【管线探针:从 .rdc 到绘制量与目标规格】---------------------------------
# 描述:用 qrenderdoc 的脚本入口跑管线探针,产出结构化 JSON(本篇取绘制量与资源规格)
# 实测:本机 RenderDoc v1.29;5 action / 2 draw / 294 索引 / RT 1264×681 / VB stride 44 / cb0 144 B
#--------------------------------------------------------------------------------------------------------
#【1】脚本入口:--python 在 UI 起来前执行,产物是 JSON,可直接进脚本二次统计
Start-Process 'C:\Program Files\RenderDoc\qrenderdoc.exe' -ArgumentList '--python','ep12_pipe_probe2.py'
#【2】脚本跑完进程不会自己退出:本轮四次实跑,每次都靠这一行收尾
Stop-Process -Name qrenderdoc -ErrorAction SilentlyContinue
#【3】读产物:先看动作数与绘制数,再看"像素着色上界"需要的两个数(目标尺寸与采样次数)
python -c "import json;d=json.load(open('pipe_probe2.json',encoding='utf-8'));print(d['total_actions'],d['draw_actions'],d['total_indices'])"
#【4】导出底图:把这一帧的颜色与深度导出来,供人眼复核尺寸
# (本机已有 res_46.png = 深度 1264×681;后备缓冲那张与 ep11 像素级相同,不启用)
下面对【1】【2】特别说明。【1】的产物是纯 JSON,代价是它跑完不退出;【2】因此是必需收尾——本轮四次实跑都要手动结束进程,模板里不写这一行,下一次就会卡在"为什么批处理不结束"。同一个坑在本篇出现两次、在 ep11 也出现过一次,说明它是 --python 入口的设计性质。
4.5 本章收在哪里
第四章走完,API 层三样东西到手:三条路径的分工与共同边界(都拿不到 GPU 时间)、绘制量真值与"像素着色上界"算式、渲染目标与深度物证。但这一章只能给"这一帧有多少活",给不出"这些活重不重"——下一章用反汇编补上后半句,并把它补成"排除"而不是"证明"。
本章对应的官方依据:管线状态视图的语义见 RenderDoc 管线状态页;命令行与脚本入口见 RenderDoc Python API 索引;另一条取数路径(回指 ep11)见 RenderDoc 性能计数器面板;trace 来源见 Perfetto 官方测试数据桶,处理器入口见 Trace Processor 下载页。
五、第 3 步:反汇编当证据用,以及四个"看起来跑通了"的坑
下面我们开始第 5 步,本篇最硬的一节。 这一章把"面板里有反汇编"升级成"脚本里能批量取反汇编(disassembly),并把它当排除性证据(exclusionary evidence)"。先说一句总口径,后面每一节都按它走:
本篇的反汇编只作排除性证据。 本轮没有任何 GPU 周期标定,能拿到的是指令条数、有无循环与动态分支、采样次数、绑定数与常量规模;拿不到的是"这些指令在 GPU 上跑了多久"。所以正文只允许写"按指令数量级看不可能吃掉 N 毫秒,故可排除 shader 过重",绝不写"实测证明 shader 不重"。
还有一条限定要先落在明处:本章与下一章用的那份抓帧,是本机自己构造的遮挡教学演示(静态场景、PS 只有一个采样)。所以第五、六章展示的是"怎么判断",不是"某个真实项目里 shader 不重"。 这一点在两处图注里各写一次。
5.1 四个调用坑:三次不报错,一次报得不像错
先来看看这一章的第一个真值:取反汇编这件事,接口在、语义清、但四处都会静默走偏。
GetDisassemblyTargets 不带参报 TypeError;DisassembleShader 第一参必须是 ResourceId;ShaderReflection 没有 disassembly 属性;目标 AMD GCN ISA 返回的是 ; Error loading atidxx64.dll. 这段说明文本,不是异常@ 本机自跑(qrenderdoc --python,v1.29)| # | 坑 | 本机实测 | 为什么危险 |
|---|---|---|---|
| 1 | GetDisassemblyTargets() 必须带 1 个布尔参 |
不带参 → TypeError |
会报错,反而最安全 |
| 2 | DisassembleShader 的第 1 参是 ResourceId |
传 PipeState → TypeError;传 ResourceId.Null() 也能出文本 |
参数类型写错会报错;但"传空也能出"会让人以为它不依赖管线 |
| 3 | ShaderReflection 没有 disassembly 属性 |
dir(refl) 里没有(取值只有 rawBytes / encoding / signatures / constantBlocks / samplers / stage / resourceId 等) |
想当然写 refl.disassembly 会拿到 None,而 None 与"没取到"看起来一样 |
| 4 | 目标 'AMD GCN ISA' 返回说明文本而不是抛异常 |
返回 ; Error loading atidxx64.dll. … ; Currently atidxx64.dll from AMD's driver package is required for GCN disassembly and it cannot be distributed with RenderDoc. |
这是本篇最值钱的静默失败案例 |
第 4 条值得单独展开。 这段文本带着分号、行首像注释,长得也像反汇编,如果把返回值直接当反汇编存盘、只检查"字符串非空",这一路就会一路绿到底。"返回了字符串"不等于"拿到了反汇编"——它与 ep11 的"枚举出 14 个计数器但取值全 0"是同一类坑:接口在、库不在。本机 plugins\ 下只有 amd / android / spirv,没有 nv;GCN 那一支要的是 AMD 驱动里的 atidxx64.dll,而它不能随 RenderDoc 一起分发。
| 事实 | 本轮实测值 |
|---|---|
目标列表(True 与 False 同值) |
['DXBC', 'AMD GCN ISA'] |
取反汇编(空目标 / 'DXBC') |
两条路都成功,得到 DXBC 文本 |
反射对象的 encoding |
ShaderEncoding.DXBC |
pipe.GetShaderMessages() |
返回空列表(D3D11 后端不填充) |
plugins\ 目录 |
amd / android / spirv,无 nv |
表里那两处都结案了。 一是 GetDisassemblyTargets 的 True:本轮把 True 与 False 各跑一遍,两者返回同一列表(['DXBC','AMD GCN ISA'])→ 在本机 D3D11 上"带不带管线"不改变目标列表。二是 GetShaderMessages() 返回空:官方 Shader Messages 页写明该功能"只在 Vulkan 实现",本机是 D3D11,所以空是"后端不填充"而不是"没有警告"(本轮在 3 份抓帧、14 个 action 上复测,全为空)。
5.2 真值一:顶点着色器 32 条指令
先来看看最长的那个。 顶点着色器的反汇编全文如下(vs_5_0,共 32 条编号指令,另有若干 dcl_* 声明行):
vs_5_0 反汇编原文(32 条指令):dcl_constantbuffer cb0(9 个常量槽)、dcl_temps 2,连续 19 条 mul / mad 做矩阵展开(全篇合计 25 条),dp3 + rsq + mul 做归一化,末尾 dp3_sat 与 mad 0.8 / 0.2 混色;无循环、无动态分支@ 本机自跑(qrenderdoc --python,v1.29,产物 SHADER_Vertex.txt 2,030 B / 46 行)Shader hash 0086dfbc-158e957d-ff581421-960a24df
vs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[9] (constants), immediateIndexed
dcl_input v0.xyz / v1.xyz / v2.xy / v3.xyz
dcl_output_siv o0.xyzw, position
dcl_temps 2
0: mul r0.xyzw, transform[1].yyyy, projection[1].xyzw
1: mad r0.xyzw, transform[1].xxxx, projection[0].xyzw, r0.xyzw
… (2–18 为 transform[0..3] × projection[0..3] 的 mul / mad 展开)
19: add o0.xyzw, r0.xyzw, r1.xyzw
20: mov o1.xy, v2.xyxx
24: dp3 r0.w, -lightvector.xyzx, -lightvector.xyzx
25: rsq r0.w, r0.w
27: dp3_sat r0.x, r0.xyzx, r1.xyzx
28: mad r0.x, r0.x, l(0.8000), l(0.2000)
31: ret
怎么把这段读成证据,有四个动作。 一看指令总数:32 条,其中连续 19 条是矩阵乘加的展开(全篇合计 25 条)——固定开销,不随场景规模变化。二看有无循环与动态分支:没有 → 成本不随数据变化,可线性估。三看有无带宽型写:无 UAV。四看常量规模:cb0[9] = 144 B → 每 draw 上传极小。四条合起来得出的是一次"排除",不是"测量"。
5.3 真值二:像素着色器 3 条指令,而它是每像素的
先来看看决定"每像素成本"的那一个。
ps_5_0 反汇编原文(3 条指令):1 次 sample_indexable(texture2d) + 1 次 mul + ret;绑定 1 texture + 1 sampler,dcl_temps 1@ 本机自跑(qrenderdoc --python,v1.29,产物 SHADER_Pixel.txt 485 B / 14 行)Shader hash bd32c67b-d7513e7c-892a3a89-c4b11b89
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_sampler mysampler (s0), mode_default
dcl_resource_texture2d (float,float,float,float) mytexture (t0)
dcl_input_ps linear v1.xy
dcl_temps 1
0: sample_indexable(texture2d)(float,float,float,float) r0.xyzw, v1.xyxx, mytexture.xyzw, mysampler
1: mul o0.xyzw, r0.xyzw, v2.xyzw
2: ret
这一段比顶点那一段更关键:它是"每像素"的。 3 条指令里只有 1 次纹理采样、1 次乘法,没有循环、分支、多次采样与 UAV 写。它决定了 4.2 节算式里"× 1"的来源,也决定了排除结论的形状:在这个样本上,像素着色的每像素成本是常数级且极小。
把 5.2 与 5.3 放一起,五个判据就齐了。 它们不是"证明 shader 快",而是"给 shader 过重这个假设设一个量级上界":
| 判据 | 本机真值 | 能得出的结论 | 不能得出的结论 |
|---|---|---|---|
| 指令总数 | VS 32 / PS 3 | 不存在"指令爆炸"的量级空间 | 不能推出"GPU 不忙" |
| 循环 / 动态分支 | 均无 | 成本不随数据变化,可线性估 | 不能推出实际周期数 |
| 采样次数 | PS 1 次 | 纹理带宽需求 = 1 × 像素数 × 格式字节(上界) | 不能推出实际带宽 |
| 带宽型写 | 无 UAV | 无"大量随机写"这类重负载特征 | — |
| 常量规模 | cb0[9] = 144 B |
每 draw 上传极小 | — |
5.4 能排除什么、不能排除什么
好了,原文看完了,把结论的形状画出来。这张表是全篇最需要逐格读清的图,每一格都要区分"排除"与"证明":
排除要有算式,不接受"因为短所以不重"。 下面这两步是本篇对"排除"这件事的完整交代:
第一步 · 每像素上界
像素着色指令总量上界 = 覆盖像素数 × 每像素指令数
= 860,784 × 3
≈ 2,582,352 条(约 258 万条)
第二步 · 与待解释的时间量级对照
系统层给本帧的待解释量级:20 ms 一档(was_slow_frame 的判据是 cpu_time > 20 ms)
若把这 20 ms 全部分给这 258 万条指令,每条指令与每次采样的可用预算约 7.7 ns
→ 只有"单条 ALU 指令 + 单次纹理采样的成本接近 8 纳秒"时,"shader 太重"这个假设才成立
其中:
- 860,784 是渲染目标 1264 × 681 的像素数,作为"参与着色的像素"的上界
- 3 是 5.3 节像素着色器的指令条数
- 20 ms 取自四档严重度的第二档判据,仅作对照量级
- 7.7 ns = 20 ms ÷ 2,582,352,是"把全部时间都算给像素着色"时的每条预算
- 本机没有周期标定,7.7 ns 只是"把 20 ms 全除给这 258 万条"的除法结果,
不是任何一条指令的成本估计,本身不构成"证明"
这两步之后,能说的话是固定的一句: 按指令数量级看,这段 shader 不可能吃掉那 20 ms,故可排除"shader 太重"。 不能说的有两句:不能说"实测证明 shader 不重"(没有测量就没有"证明"),不能说"GPU 不忙"。
5.5 把这一章落成一段脚本
先来看看本章的落地动作:先问目标列表,再取反汇编,最后把四个坑逐条验一遍。
#-----------------------------------【反汇编三步:问目标 / 取文本 / 验坑】-----------------------------------
# 描述:qrenderdoc --python 跑反汇编探针,产物为 JSON + 两份指令文本
# 实测:目标列表 ['DXBC','AMD GCN ISA'];VS 32 条 / PS 3 条;GCN 目标返回说明文本;四次实跑均需 Stop-Process
#--------------------------------------------------------------------------------------------------------
#【1】取反汇编:脚本里先 GetDisassemblyTargets(True),再用 DisassembleShader 取文本
Start-Process 'C:\Program Files\RenderDoc\qrenderdoc.exe' -ArgumentList '--python','ep12_shader_probe4.py'
#【2】--python 跑完不退出(本轮四次实跑均如此),必须手动收尾
Stop-Process -Name qrenderdoc -ErrorAction SilentlyContinue
#【3】自检一:目标文本是不是"说明文本"而不是指令——设一个断言,别只看长度
python -c "t=open('SHADER_Vertex.txt',encoding='utf-8').read();assert 'vs_5_0' in t, '未取到反汇编(可能是说明文本)';print('OK')"
#【4】自检二:有没有厂商 ISA 的目录级前提——本机 plugins 下无 nv,GCN 要的是 AMD 驱动里的 atidxx64.dll
Get-ChildItem 'C:\Program Files\RenderDoc\plugins\' -Directory | Select-Object Name
下面对【3】【4】特别说明。【3】是给那个静默失败案例配的兜底:判断"有没有拿到反汇编"要靠内容特征(如 vs_5_0 / ps_5_0 字样与指令编号),不能靠"返回了非空字符串"。【4】说明"接口在、库不在"在反汇编这一腿上的具体形态:它要的库在 AMD 的驱动包里,RenderDoc 不能替你装好。
5.6 本章收在哪里
第五章走完,第 5 步该有能力了:四个调用坑("返回说明文本"最值钱)、VS 32 条 / PS 3 条的原文与读法、五条判据的"能/不能"边界、两段量级算式。下一章把这一章的输出接到前两步上,给出三档结论。
本章对应的官方依据:反汇编页签与多种形态(含硬件 ISA)见 RenderDoc Shader Viewer 页;两个 API 的签名与"失败时返回错误说明文本"的语义见 RenderDoc Python API 的 replay 页;
atidxx64.dll的配置见 RenderDoc wiki 的 GCN-ISA 页;版本线见 RenderDoc Releases。
六、排除法与结论落点:三档结论,其中一档是"数据不足"
下面我们开始第 6 步。 前面五步把两层的证据都取到了,这一章把它们合成结论。本篇的结论只有三档,而且第三档不是失败,是一个正常出口——这一点是 ep10、ep11 一直在守的纪律,本篇继续守。
6.1 三档结论的形状
先来看看结论的形状。三档的分界线不是"证据多不多",而是"证据够不够回答被问的那一问":
| 档 | 触发条件 | 输出措辞(唯一允许的说法) |
|---|---|---|
| 可排除 | 指令与绘制量在量级上不可能吃掉待解释的时间,且无循环 / 动态分支 / 多次采样 / UAV 写 | "按指令数量级看不可能吃掉 N ms,故可排除 shader 过重" |
| 不能排除 | 指令里出现循环、动态分支、大量采样、UAV 写,或渲染目标很大 | "本帧的量级足以解释这段时间,需继续查 shader 与带宽" |
| 数据不足 | 超期帧挂不上任何线程切片;或 jank_type = None 却 overrun > 0;或只有 CPU 时间总量而没有 GPU 时长 |
"现有证据不足以判定,需补什么数据" |
这张表存在的理由是第一档与第三档的措辞。 第一档必须配算式(5.4 节那两步),不接受"因为短所以不重";第三档必须写出"缺什么",不接受"看起来不像"这类感觉型结论。
6.2 第一档:本机的可排除实例与它的算式
先看本篇唯一一个"可排除"的实例,也就是第五章那份抓帧。 它的量级算式已经在 5.4 节给出,这里只把结论形态再钉一遍:
- 指令侧:VS 32 条 / PS 3 条,无循环、无动态分支、无 UAV 写,PS 只有 1 次采样;
- 绘制侧:2 个绘制 / 294 个索引 / 渲染目标 1264×681(像素上界 860,784);
- 对照量级:四档严重度的第二档
cpu_time > 20 ms; - 结论:按指令数量级看,这段 shader 不可能吃掉那 20 ms,故可排除"shader 太重"。
这段结论里有两处必须与它一起写。 一是"可排除的是这一段 shader 本身",不是"GPU 不忙"——本帧之外还有别的绘制、别的进程、别的带宽需求。二是这份抓帧是构造性教学演示(静态场景、PS 只有一个采样),它"轻"几乎是必然的;本篇演示的是"怎么判断",不是"某个真实项目里 shader 不重"。
6.3 第二档:什么样的指令清单会让"不能排除"成立
第二档是本篇给读者的反向清单。 如果反汇编里出现下面任意一条,"shader 太重"这个假设就不能被排除,判别链必须继续往下走:
| 触发特征 | 为什么它推翻"可排除" |
|---|---|
出现 loop / breakc / if 等循环与动态分支 |
成本不再随数据线性,也可能被分支与发散拖长 |
采样次数多(多次 sample_*) |
纹理带宽需求按采样次数成倍增长 |
| 出现 UAV / 无序写 | "大量随机写"是典型的重负载特征 |
| 渲染目标很大或一帧有多个 pass | 覆盖像素上界会被极大抬高 |
| 指令数本身很大(上百条以上) | 量级上就存在"吃掉时间"的空间 |
这张表的关键是"量级"两个字。 它不是"看到循环就一定是瓶颈",而是"看到这些特征就不能用数量级排除掉"——排除法只能排除,不能定罪;真正的定罪需要 per-draw 或 per-pass 的 GPU 耗时,而那正是本机拿不到的。
6.4 第三档:本机"数据不足"的三种实例
第三档不是本篇的免责声明,它是本篇拿到最多真值的一档。 三种触发条件在本机都有实例:
| 触发条件 | 本机真值 |
|---|---|
| 超期帧挂不上线程切片 | 487 个正超期帧里只有 16 个(3.3%)能挂上 Choreographer#doFrame / DrawFrame |
jank_type = None 却 overrun > 0 |
最大样本里可归因的 12 帧中,1 帧 jank_type = None、2 帧是 Buffer Stuffing(状态而非 jank) |
| 只有 CPU 时间总量、没有 GPU 时长 | FetchCounters 全 0、plugins\ 无 nv、jank_type GLOB '*GPU*' 六份全 0 行 |
三条里最有说服力的是第一条与第三条的组合:前者说明"哪条线程的锅多数时候问不出来",后者说明"是不是 GPU 的锅,系统层结构上就不回答"。这不是偷懒,是这台机器上确实没有那份数据。
再说一句关于极值的判断。 最极端的两帧是 1884 ms 与 637.9 ms;1884 ms 相当于 60 FPS 帧预算的 113 倍——这个量级上"着色器太重"连假设的资格都没有,它更可能是进程被挂起或被别的东西阻塞。把这类帧送进"排除 shader"的流程是资源浪费,它应该直接进"数据不足 / 另有阻塞"。
6.5 把这一章落成一步判断
下面这段伪代码是本章的落地动作,它把三档结论写成可执行的分支。它的顺序:先问"能不能回答",再问"能不能排除"。
输入:一个正 overrun 的帧
1 若该帧挂不到任何线程切片,且没有 GPU 时长 → 输出"数据不足",并列出缺哪一份数据
2 否则若 jank_type/jank_tag 指向合成侧 → 输出"该查合成,与 shader 无关"(本篇不继续)
3 否则(应用侧)→ 取这一帧的绘制量与反汇编指令清单:
3a 若出现循环/动态分支/多次采样/UAV/大 RT → 输出"不能排除"(第 6.3 节清单)
3b 否则给出量级算式(像素上界 × 每像素指令数)→ 输出"可排除 shader 过重"
这段伪代码里最能省时间的是第 1 步。 它让"数据不足"在翻反汇编之前就被识别出来——先问能不能回答,再问答案是什么,顺序颠倒的话所有工作都可能白做。
6.6 本章收在哪里
第六章走完,第 6 步的落点清楚了:三档结论的触发条件与唯一允许的措辞、可排除实例与算式、"不能排除"的反向清单、"数据不足"的三种本机实例。下一章把全篇的坑与边界收齐。
本章对应的官方依据:逐 draw 计时的限定(不包含 pipeline overlap、不如硬件计数器准)见 Vulkan 指南 Profiling 页;"按 Duration 排序找贵 shader"的隐藏前提见 Godot 着色器剖析教程;帧预算与刷新率见 Android 官方平台性能度量页(镜像站)。
七、边界与陷阱:取不到、取到了但不是你想的、以及为什么对不上同一帧
好了,下面做收尾前的最后一次体检。 这一章把本篇踩到的坑集中钉一遍,并专门用一节讲清楚"为什么同帧因果链在本机做不到"。这些坑的共同特征是"不报错":不报错地给 0、不报错地给一段说明文本、不报错地报出两个口径不同的 draw 数。在性能分析里,不报错的错最贵。
7.1 陷阱矩阵:五条不带报错的错
| # | 陷阱 | 实跑标记 | 正确姿势 |
|---|---|---|---|
| 1 | 同一 frame_id 在不同 layer 上 jank_type 可以不同 |
本机实例:单帧 1884 ms 在 4 个 layer 上给出 3 种取值(Buffer Stuffing / None / None) |
归因要带 layer 一起说,别把"一帧一个归因"当前提 |
| 2 | 把 overrun > 0 当成归因 |
12 个可归因超期帧里 1 帧 jank_type = None、2 帧是 Buffer Stuffing |
超期归超期、归因归归因;不一致时以归因为准 |
| 3 | 反汇编的四个调用坑 | 见 5.1 节;尤其目标 AMD GCN ISA 返回说明文本 |
判断"有没有拿到反汇编"看内容特征,不看返回是否非空 |
| 4 | 桌面与移动证据不可混用 | trace 是 Mali / Exynos 的 Android 数据;.rdc 是桌面 D3D11 |
只比方向与量级,不做同帧推论 |
| 5 | 跨工具同一帧对不上 | 官方自陈:两个工具的 event 编号不会相同 | 需要同设备 + 同时在采 + 可对齐的键(见 7.2 节) |
第一条是本篇新发现的坑,也最隐蔽。 它意味着"这一帧的 jank_type 是什么"这个问题本身就不完整——正确的问法是"这个 frame_id 在哪一层上的 jank_type 是什么"。用 android_frames_layers 之前要先知道:layer 是问题的一部分。
7.2 为什么同帧因果链在本机做不到
这一节把最容易含糊过去的一句话讲透。 本篇的印证口径之所以降到"分层 + 方向 + 量级自洽",是因为"跨工具对上同一帧"要同时满足三个条件,缺任何一个都只能后退:
| 条件 | 含义 | 本机为什么不满足 |
|---|---|---|
| ① 同设备 | 两份证据必须来自同一块 GPU、同一套驱动与后端 | 六份 trace 是 Mali / Exynos 的 Android 测试数据,.rdc 是 RTX 3060 的 D3D11 抓帧 |
| ② 同时在采 | 两侧必须覆盖同一段时间 | 两者相隔多日,中间隔着完全不同的实验 |
| ③ 帧号或时间戳可对齐 | 两侧要有共同的等值键(同一帧号,或可换算的时间戳) | 桌面抓帧是"被选中的那一帧",键是帧内事件序号;Android trace 是"一段时间里的所有帧",键是帧标识 / 时间戳 |
| ④ 官方自陈 | 即便做了双向联动的两个工具,也不承诺编号一致 | RGP 联动页:由于两者对帧的作用域、目的与视图都不同,「the numbering of events will not be the same between the two tools」 |
第三条最难,也最容易被忽略。 抓帧工具给的是"帧内下标",系统采集给的是"全局时间",两者没有天然交集。这不是 bug,是两个工具对"帧"的定义本就不同。要让两者对上,需要的不是更聪明的 SQL,而是一条软件栈级的协同链。
所以本篇的处理是三条,写在明处:
- 把印证钉在"分层 + 方向 + 量级自洽"——正文每一处交叉引用都不写成"因此这一帧就是 shader 的问题";
- 禁止一类推论:不许出现"因为 Android 那帧是应用侧超期,所以这张桌面抓帧的 shader 有问题"这种跨设备的因果嫁接;
- 把"要什么条件才能对上"写成清单:凑齐同设备 + 同时在采 + 一个共同的等值键,是 ep13《如何验证的闭环手册》的输入——这三条不是"以后配置好就行",第三条要求两侧对"帧"的定义可以互相换算,而连做了双向跳转的 RGP 与 RenderDoc 都不承诺编号一致。
7.3 "接口在、库不在":同一设计的两处落点
好了,这一节把本篇与 ep11 的缺口收成一条结论。 它们看起来是两件互不相干的事,其实是同一个设计:
plugins\ 无 nv、FetchCounters 全 0),厂商 ISA 要 atidxx64.dll(本机目标只返回说明文本)——能拿到的是与厂商无关的指令与绘制量,拿不到的是与厂商绑死的 ISA 与 counter@ 本系列绘制| 想要的东西 | 接口在哪 | 缺什么 | 本机实测 |
|---|---|---|---|
| 硬件性能计数器 | RenderDoc 有面板与两个 API | NVIDIA 的 NvPmApi.Core.dll / nvperf_grfx_host.dll(许可证禁止随包分发) |
枚举 14 条,取值全 0;plugins\ 无 nv |
| 厂商 ISA(GCN) | 反汇编目标列表里有 AMD GCN ISA |
atidxx64.dll(同样不能随 RenderDoc 分发) |
目标返回 ; Error loading atidxx64.dll. 说明文本 |
这张表最该记住的是两处的同一个句式:菜单里有,但你采不到,原因是厂商的库不能随包走。 而能拿到的(DXBC 指令清单、绘制量、资源规格)恰好都只依赖 D3D11 的通用能力——这既是"用指令与绘制量做排除"能走通的原因,也是它能力上限的原因。
7.4 平台口径矩阵:同一个中文词,两种量
坑的根源多数不在工具,在词——"帧时间限制""GPU 使用率"这两个词,两侧各自指向不同的量:
| 平台 | 对象 | 语义 | 单位 | 出处 |
|---|---|---|---|---|
| 两侧 | 帧时间限制 | 1000 ÷ 目标 FPS 现算:60 FPS → 16.67 ms、40 FPS → 25 ms、30 FPS → 33.3 ms |
ms | 本篇口径 + Android 官方举例 |
| 桌面 | 帧内 API 耗时 | CPU 调用图形 API 的时间,不含 GPU 执行与逻辑时间 | µs | renderdoccmd convert(回指 ep11) |
| 移动 | cpu_time |
含"CPU 在算"与"等 GPU"两件事,不可直接读成 CPU 忙 | ms | android_frame_stats |
| 移动 | jank_type / jank_tag |
归因,不是"超期" | 字符串 | FrameTimeline |
| 移动 | GPU 侧 | 只有 gpufreq / GPU Memory 这类旁证 |
Hz / 字节 | ep11 |
这张表的作用是"防止跨列对齐"。 看到"桌面帧内 12.0 µs"与"移动 cpu_time 114 ms"就觉得可以比,是把不同单位、不同语义的量塞进同一个不等式——而"方向"要先折算成"相对帧预算的位置"才有意义:12.0 µs 在 16.67 ms 的预算里占 0.07%,在 1 ms 的预算里占 1.2%。
顺带更正一处旧值:顶点缓冲 stride 本轮实测 44 B(ep11 记的是 40);本篇一律用 44,并把 40 记为旧值待回填。
7.5 本章收在哪里
第七章走完,五条不报错的陷阱、同帧对齐的三个必要条件与官方自陈、"接口在、库不在"的两处同构、平台口径矩阵与一处旧值更正都钉住了。下一章收口。
本章对应的官方依据:"两个工具事件编号不会相同"的自陈见 RenderDoc RGP 联动页;互操作范围(仅 DX12 与 Vulkan)见 RGP 手册互操作章节;
atidxx64.dll要求见 RenderDoc wiki 的 GCN-ISA 页;Android 抓帧前置条件见 RenderDoc 抓帧页;列条目见 Perfetto SQL 表参考。
八、收口清单与结语
好了,最后一章做三件事: 把全篇压成一张可截图的清单,把边界再确认一遍,把下一站指清楚。
8.1 收口清单:症状 / 系统层 / API 层 / 印证点 / 边界
先来看看这张"随身卡":左边是症状,中间两列是两层该看的数,第四列是印证口径,第五列是边界。
| 症状 | 系统层能问到哪 | API 层取什么 | 印证点 | 边界 |
|---|---|---|---|---|
| 帧率掉了,不知记谁头上 | 先看 jank_type / jank_tag,落到合成侧就停 |
不动 | 两侧同指"非应用侧" | 唯一带 GPU 名字的条目在合成侧 |
| 应用侧超期,不知哪条线程 | 487 帧里只有 16 帧能挂上线程 | 不动 | 挂不上 → 数据不足 | 3.3% 只对本机 6 份 trace |
| 应用侧超期,怀疑着色器 | cpu_time 含"等 GPU",不可直接读 |
绘制量 + 反汇编指令清单 | 量级自洽 → 可排除 | 排除 ≠ 证明,无周期标定 |
| 拿不到 GPU 时长 | 结构上不给 GPU 分解(max(gpu, post)) |
FetchCounters 全 0、plugins\ 无 nv |
数据不足是正常出口 | 接口在、库不在 |
| 想对上同一帧 | 帧切片(全局时间) | .rdc 帧内事件序号 |
只比方向与量级 | 三条件缺一不可 |
再压一层,本篇只有四个动作:先分流责任方再谈归因(顺序不能反)、排除必须带算式、数据不足要写成正常出口并说明缺什么、同帧对不上就只比方向。四条都做到,这条链就不会给出反向结论。
8.2 边界再确认表
0.5 节给过一次边界,走完全篇后再确认一次:
| 本篇讲了 | 本篇没讲(→ 属哪篇) |
|---|---|
| 六步判别链与三档结论 | 帧注入、回放、事件树与资源视图读法 → ep01 到 ep06 |
| 责任方分流的出口与真值 | FrameTimeline 判据推导与 8 条目归因 → ep10 |
cpu_time 拆解与"为什么必须换腿" |
GPU 忙闲判定方法论 → ep11 |
| 绘制量、像素着色上界与反汇编排除法 | 反汇编面板操作、IR 形态与源码映射 → ep06 |
| "接口在、库不在"的两处同构 | 凑齐同帧对齐三条件的闭环手册 → ep13 |
最后一行是本篇唯一的"向前开口":把"对不上同帧"拆成三个可检查的条件,凑齐并写成流程,是 ep13 的题域。
8.3 资源清单与参考资料来源
- RenderDoc Shader Viewer —— https://renderdoc.org/docs/window/shader_viewer.html
- RenderDoc Python API 的 replay 页 —— https://renderdoc.org/docs/python_api/renderdoc/replay.html
- RenderDoc Python API 索引 —— https://renderdoc.org/docs/python_api/index.html
- RenderDoc 管线状态视图 —— https://renderdoc.org/docs/window/pipeline_state.html
- RenderDoc 性能计数器面板 —— https://renderdoc.org/docs/window/performance_counter_viewer.html
- RenderDoc 与 RGP 联动页 —— https://renderdoc.org/docs/how/how_rgp_profile.html
- RenderDoc 抓帧页 —— https://renderdoc.org/docs/how/how_android_capture.html
- RenderDoc wiki 的 GCN-ISA 页 —— https://github.com/baldurk/renderdoc/wiki/GCN-ISA
- RenderDoc Releases —— https://github.com/baldurk/renderdoc/releases
- Perfetto FrameTimeline 数据源页 —— https://perfetto.dev/docs/data-sources/frametimeline
- Perfetto GPU 数据源页 —— https://perfetto.dev/docs/data-sources/gpu
- Perfetto CPU 频率文档 —— https://perfetto.dev/docs/data-sources/cpu-freq
- Perfetto 事件轨道文档 —— https://perfetto.dev/docs/instrumentation/track-events
- Perfetto SQL 表参考 —— https://perfetto.dev/docs/analysis/sql-tables
- Perfetto 标准库文档索引 —— https://perfetto.dev/docs/analysis/stdlib-docs
- Perfetto 标准库
android/frames/jank_type.sql—— https://github.com/google/perfetto/blob/main/src/trace_processor/perfetto_sql/stdlib/android/frames/jank_type.sql - Perfetto 标准库
android/frames/per_frame_metrics.sql—— https://github.com/google/perfetto/blob/main/src/trace_processor/perfetto_sql/stdlib/android/frames/per_frame_metrics.sql - Perfetto 标准库
android/gpu/目录 —— https://github.com/google/perfetto/tree/main/src/trace_processor/perfetto_sql/stdlib/android/gpu - Trace Processor 命令行参考 —— https://perfetto.dev/docs/reference/trace-processor-cli
- Trace Processor 下载入口 —— https://get.perfetto.dev/trace_processor
- Perfetto 官方测试数据桶 —— https://storage.googleapis.com/perfetto/test_data/
- Perfetto UI 使用说明 —— https://perfetto.dev/docs/visualization/perfetto-ui
- Android 官方 jank 检测页 —— https://developer.android.google.cn/studio/profile/jank-detection
- Android 官方《分析和优化游戏性能》—— https://developer.android.google.cn/games/optimize/gameperformance
- Android 官方平台性能度量 —— https://developer.android.google.cn/topic/performance/measuring-performance
- Vulkan 指南 Profiling 页 —— https://docs.vulkan.org/guide/latest/profiling.html
- Godot 着色器剖析教程 —— https://bugnet.io/blog/how-to-profile-shaders-in-godot-4
- GPU Profiling 入门 —— https://bugnet.io/blog/gpu-profiling-for-game-developers-beginners-guide
- RGP 与 RenderDoc 互操作(厂商手册)—— https://gpuopen.com/manuals/rgp_manual/renderdoc_and_rgp_interop/
参考资料来源:系统层真值来自本机对六份官方 Android 测试 trace 的实跑(trace_processor_shell v58.2-add693d8b);API 层真值来自本机自跑(RenderDoc v1.29 的内嵌 Python 探针,桌面 D3D11);机制与判据取自官方一手文档,厂商手册与社区教程仅作旁证。"拿不到 GPU 计数器与厂商 ISA"在第五、七两章各写明一次,未用近似值顶替。
8.4 下篇预告与落款
下篇预告:ep13《实战三:如何验证的闭环手册》——本篇把"对不上同一帧"拆成同设备 + 同时在采 + 一个共同的等值键三个可检查的条件,也把"数据不足"写成了正常出口。把这三条凑齐、写成一套可复用的 SOP,就是 ep13 的全部内容。
以上。 希望这一路"从一帧超期到该记在谁头上"的梳理,能让你下次听到"是不是着色器太重"时不再直接翻源码,而是一路问下去:这一帧记在 app 还是 SF、系统层问到了哪一步、量级算式对不对得上,以及——现在的数据够不够回答问题。
本文由@溯光 出品,让"jank 还是 shader"变成一个可复现的顺序判断。
With Best Wishes.
[^ext1]: 取自 Perfetto 官方文档 docs/images/android-trace-analysis-cpu-utilization-process.png(配套 https://perfetto.dev/docs/getting-started/android-trace-analysis),Google,Apache-2.0;实测 2088×872、212,844 B、md5 725d116c200a,未命中前篇。
[^ext2]: 取自 Perfetto 官方文档 docs/images/flows.png,Google,Apache-2.0;实测 1016×90,长边不达标,按原理小图落地。
[^ext3]: 取自 Perfetto 官方文档 docs/images/frametimeline/select-sf-slice-2.png,Google,Apache-2.0;实测 798×528(ep10 未选用的那一张、md5 未命中前篇),按原理小图落地。
[^ext4]: 取自 Perfetto 官方文档 docs/images/cpu-frequency.png,Google,Apache-2.0;实测 1852×196、23,913 B、md5 a590ac8ab737。
[^ext5]: 取自 Perfetto 官方文档 docs/images/analysis-cookbook-unint-sleep.png,Google,Apache-2.0;实测 1686×256、111,938 B、md5 eacc24fcefeb。
[^ext6]: 取自 Perfetto 官方文档 docs/images/android-trace-analysis-background-jobs.png,Google,Apache-2.0;实测 1891×688、280,767 B、md5 80dd687cb4cd。
[^src1]: 自跑:六份官方测试 trace 的 android_frames_overrun —— 行数 1395 / 215 / 774 / 202 / 110 / 16,正超期 236 / 42 / 149 / 24 / 33 / 3 = 487;极值 +1,884,166,196 ns 与 +637,885,146 ns;min 六份均在 −13.9 至 −20.5 ms 之间。
[^src2]: 自跑:六份 trace 的 jank_type / jank_tag 分布,见 2.2 节两表;api31_startup_warm.perfetto-trace 是本篇唯一"合成侧类型齐全"的样本。
[^src3]: 自跑:jank_type GLOB '*GPU*' 与 args 关键词检索在 6/6 份 trace 上均为 0 行。
[^src4]: 自跑:pipe_probe2.json —— 5 根动作 / 2 绘制(numIndices = 6 与 288)/ 合计 294 索引 / Topology.TriangleList / RT 1264×681 B8G8R8A8_UNORM / DS D24S8 / VB stride 44 / cb0 144 B / GetShaderMessages() 空。
[^src5]: 自跑:深度缓冲导出真图 1264×681、34,984 B、md5 719cfcc600d0;同目录后备缓冲导出图与 ep11 已用帧缩略图逐像素相同(平均差 0.0 / 最大差 0),本轮不启用。
[^src6]: 自跑:shader_probe4.json —— 目标列表(传 True)= ['DXBC','AMD GCN ISA'];空目标与 'DXBC' 均成功;ShaderReflection 无 disassembly 属性;GCN 目标返回 ; Error loading atidxx64.dll. 说明文本。
[^src7]: 自跑:SHADER_Vertex.txt(2,030 B / 46 行,vs_5_0 32 条指令)/ SHADER_Pixel.txt(485 B / 14 行,ps_5_0 3 条指令);两份均无循环、无动态分支。
[^r1]: RenderDoc 原文:管线状态视图总是显示 action 执行之后的状态;它用 shader 反射数据只显示实际在用的槽;绑定为空时以红色背景显示;可把整份状态导出为 HTML。
[^r2]: RenderDoc 原文:对使用字节码 IR 的 API,"该字节码的反汇编也会显示在一个独立页签里";上方有下拉列出可能的表示形式,包括 AMD GCN 这类硬件相关 ISA。
[^r3]: RenderDoc API 文档原文:DisassembleShader 的返回是"the disassembly text, or an error message if something went wrong"——返回说明文本是这份接口的既定行为,不是异常。
[^r4]: RenderDoc 原文:RGP 集成默认关闭;两个工具对帧的作用域、目的与视图都不同,「the numbering of events will not be the same between the two tools」。
[^p1]: Perfetto 原文:FrameTimeline 的目标不是进入应用内部找原因,而是"提供一个快速判断应用还是 SurfaceFlinger 卡顿的方法";actual 轨的终点等于 max(gpu time, post time),并说明"GPU 通常并行运行,因此 GPU 可能比 post 更晚结束"。
[^p2]: Perfetto 原文(回指 ep11):数据源常以带硬件后缀的名字注册(如 gpu.counters.adreno),跟踪服务使用精确名匹配;跨层对齐的两套机制为 event_wait_ids 与 GpuCorrelation。
[^a1]: Android 官方(镜像站):流程第 2 步"CPU 和 GPU 绑定检查"列 Perfetto、第 4 步"GPU 优化"列 RenderDoc;判据含 25 毫秒(约 40 FPS)、33.3 毫秒(30 FPS)、>90%、>15%。
[^a2]: Android 官方(镜像站):Android 11 的四条轨为 Application / Wait for GPU / Composition / Frames on display;官方明确"这并不表示 GPU 在此期间仅使用相应缓冲区的内容工作"并指向 GPU 检查器;16 毫秒出自 60 FPS 截止时间。
[^v1]: Vulkan 官方指南原文:逐 draw 的 timing「aren't as accurate as hardware counters」(不包含 pipeline overlap),但可用于找出"哪一段是主要的时间黑洞"。
[^b1]: 社区教程原文(旁证):F12 抓帧后按 Duration 降序排,最上面几条即最贵的着色器——前提是"事件浏览器里有逐 draw 的 GPU 时间",而本机 FetchCounters 全 0,该前提不成立。