图形性能剖析工具链 · 第 十一 篇

实战一:一条链路揪 GPU 瓶颈

2026-09-2517,324 字26 张配图本文由@溯光 出品,转载请注明出处

CSDN 版文章链接:待发布 知乎专栏版文章链接:待发布

题图:Perfetto 官方给出的 GPU 独立轨道实景——GPU 频率与内存计数器挂在 dev.perfetto.Gpu 插件生成的轨道下,"GPU 忙不忙"在系统层本来就有
题图Perfetto UI 里 GPU 是一条独立轨道,频率与内存计数器挂在其下——"GPU 忙不忙"这件事,系统层本来就有迹可循@ Perfetto 官方文档(Google,Apache-2.0)

点题、总览与边界:从"感觉卡"到"哪一层能回答"

先说说这一篇要干什么。 前面十篇一直在"装零件":ep01 到 ep06 拆开 RenderDoc 的抓帧、回放、事件树与资源视图,ep07 到 ep10 讲透 Perfetto 的文件结构、数据源、SQL 调优与帧判据。零件齐了,这一篇开始装机器——回答一个具体的症状问题:这台机器跑得不快,是 GPU 忙不过来,还是 GPU 在等?

这一问听着简单,做起来有三处会走偏。 第一处是把"GPU 使用率高"当成"GPU 是瓶颈"——使用率高只说明它有事干,不说明它干的事必要;第二处是拿一个平台的阈值去量另一个平台,把移动游戏语境的 25 ms 直接搬到桌面 60 FPS 的 16.67 ms 预算上;第三处是以为两个工具能对上同一帧——本机做不到,官方文档自己写了做不到,而这一篇要讲的恰恰是"对不上的时候,这条路还怎么走"。

本篇的边界画在"只做串联"这四个字上。 本篇是本系列《图形性能剖析工具链》的第 11 篇,也是 PART C 自跑实战的第一篇:

  • 不复述 RenderDoc 的 API 层基础。抓帧怎么注入、Event Browser 的树怎么读、纹理查看器与反汇编面板怎么用,是 ep01 到 ep06 的全部内容;
  • 不复述 Perfetto 的采集与分析基础。trace 文件的 protobuf 结构是 ep07,data source 的注册与路由是 ep08,PerfettoSQL 的语法与调优是 ep09,FrameTimeline 的判据与归因是 ep10;
  • 本篇只做一件事:把这两个盘子的东西串成一条可跑的排查链路——一个症状进来,先在哪一层问什么、拿到什么样的数、在哪儿把两条腿放在一起看、最后结论怎么落地。每一站的机制都不重讲,只讲"这一站该问什么、答案长什么样"。

还有一处边界要说在明处:本篇的交叉印证只做到"方向一致",做不到"同一帧"。 为什么做不到,第七章给三条本机实测加一条官方自陈;为什么"方向一致"仍然够用,第六章给判据。这个口径由用户提前定下,我们不美化它。[^ext1]

0.1 三句抱怨,三种数据形态

先来看看真实工作里最常听到的三句抱怨。 它们的共同点是:都能被数据回答,但回答它们需要的数据形态不一样。 把这三句翻译成数据,一条链路该走哪几步就清楚了。

抱怨原话 数据上是什么意思 该先问哪一层
"帧率上不去,一帧三十多毫秒" 帧时间超预算,且 GPU 侧完成时间很可能是大头 系统层先看忙闲方向,再落 API 层
"GPU 压根没吃满,帧就是快不起来" GPU 空转——它在等 CPU、等提交、等 fence 系统层看频率与工作周期,API 层看 CPU 侧调用
"GPU 看着挺忙,画面却没变好" GPU 忙,但忙的是被浪费掉的活(典型是被遮挡的像素) API 层的逐 event 开销 + 遮挡类计数器

先来看看第一句,它最容易查,也最容易被误查。 帧时间超预算是可观测量,但"超预算是 GPU 造成的"不是——它要两条证据同时成立:GPU 侧完成时间接近或超过帧预算,且系统层看到 GPU 处于高负载方向。只看一个指标就下结论,是本篇要拦住的主要错误。

第二、三句才是这条链路的价值所在。 它们各对应一种"看起来正常"的假象:GPU 没吃满就收工说"GPU 不是瓶颈",真问题却是 CPU 提交太碎;GPU 吃满就说"该换卡了",真问题却是画了半屏看不见的像素。这两种假象,两层各能证一半。

把这三句并成一句题眼:GPU 瓶颈不是一个指标,是一个"方向 + 量级"的组合判断。 九章顺着它排开:零章立边界与地图,一章给六步链路的全貌,二章讲怎么先判方向,三章讲 API 层能取到哪三样,四章讲取不到什么,五章讲系统层的两条硬证据,六章讲两条腿怎么合起来看,七章讲陷阱,八章收口。

0.2 全篇知识地图

下面这张表是后面九章的顺序与产出。它不是并列的知识点,而是一条排查动线——顺序不能颠倒:先判方向才决定要不要深挖,先有量级才谈得上印证。

章节 在回答什么 你会拿到什么
零、点题与边界 这一篇讲什么、不讲什么? 知识地图 + 工具版本线 + 术语双语表 + 边界声明
一、一条链路的全貌 六步怎么走、两个落点怎么分工? 症状 → 判方向 → API 层 → 系统层 → 印证 → 结论的固定动作序列
二、判定方向 GPU 忙、GPU 等、还是帧里没活? 三分支判定树 + 三条阈值 + 第一份桌面真值
三、API 层取数 不打开界面能取到哪三样? convert / thumb / --python 三件套 + 帧内统计与 Top API 真值
四、API 层的天花板 为什么"菜单里有"不等于"采得到"? 三种硬件计数器来源与安装路径;本机"枚举 14 条(13 个通用计数器 + 1 条厂商占位报错)"实测
五、系统层取数 官方给了哪两条硬证据? gpufreq 与 GPU Memory 真值 + 四模块分工
六、交叉印证 两条腿怎么合起来看? "方向一致"的判据 + 三类结论矩阵 + 遮挡实验
七、边界与陷阱 哪里会取不到、哪里会取错? 五列口径矩阵 + 陷阱矩阵 + "为什么对不上同一帧"
八、收口清单与结语 下次遇到同类问题怎么办? 症状 / API 层 / 系统层 / 印证点 / 边界 五列清单

0.3 工具版本线:本机 v1.29 与官方 v1.46 之间的十七个小版本

先说年份口径:下表的时间一律以该能力进入官方文档体系或本机可核的版本号为准,取"能公开查到的那个时间点",不是内部提交时间。本机所有实测都跑在这套版本上,后文每处数字都要回指它。

阶段 时间 / 版本 关键变化 对本篇的影响
RenderDoc 起步 早期版本(引入年份本轮未核) 抓帧 / 回放 / 事件树成型,成为 PC 端图形调试事实标准 ep01 到 ep06 讲的全部内容
加性能计数器面板 引入版本未在官方文档标注 支持 AMD GPA、NvPerfKit、Nsight Perf SDK 三种硬件计数器来源 第四章主线,也是本机拿不到 counter 的根源
RenderDoc 本机版本 v1.29(build 0202a066a9ffa4077fa980da2d97ad5fb6ec5827) 内嵌 Python 3.6,--python / --ui-python 双入口可用 本篇三、四两章的实测环境
RenderDoc 官方最新 v1.46 内嵌 Python 升至 3.8;仍可作独立 Python 模块使用 本机落后 17 个小版本
Perfetto trace 处理器 v58.2-add693d8b(Windows,RPC API v14) GPU 计数器、GPU 频率、GPU 内存等表族齐备 本篇第五章的实测环境
Android 侧采集门槛 Android 6.0 起(RenderDoc 官方支持 Vulkan 与 OpenGL ES) 包必须已安装且可调试(debuggable)才能抓帧 本篇做不到"现抓一份真机帧"的官方原因之一

这张表里最该记住的是第四行。 本机 v1.29 不是"差不多够用"的旧版本:内嵌解释器是 3.6(官方 1.46 已是 3.8),且它出厂不带任何 NVIDIA 的计数器插件——后者不是配置问题,是许可证问题,第四章给官方原文与本机目录反证。版本号取自本机安装目录与官方 releases 页。

0.4 术语双语对照(先备着)

先来看看这套术语的双语对照。 后面几章会密集出现官方术语原名,中文侧译法并不统一,这里一次钉死:

中文 English 一句话
硬件性能计数器 hardware performance counter GPU 内部按事件计数的寄存器级指标,逐 event 采样
时间加权平均利用率 time-weighted average utilization 把稀疏采样点展开成区间后按区间时长加权的平均占用
可见开销量级 visible overhead magnitude 只能看得见"顺序与条数"、看不见 GPU 内部计数的代理指标
帧预算 frame budget 一个刷新周期内允许一帧花掉的时间上限
GPU 频率 GPU frequency GPU 当前频率档位;升频↔有活、降频↔空闲
厂商后缀 vendor suffix 数据源名后的厂商标识(如 gpu.counters.adreno),写错即静默收不到
计数描述符 counter descriptor 描述每个计数器含义与单位的元数据块
交叉印证 cross-validation 两条独立证据链指向同一结论的过程
遮挡剔除 occlusion culling 不绘制最终看不见的物体,减少被深度测试丢弃的像素
时间线围栏 DRM fence 内核侧描述 GPU 工作完成时刻的同步对象

一句话记住本篇的方法:先用系统层判方向,再用 API 层定量级,最后问两条腿的方向对不对得上。

0.5 边界声明与量化锚点

边界在开篇说一次,第八章会再确认一次:

  • 不复述 API 层基础:抓帧注入、回放、Event Browser、纹理查看、反汇编属 ep01 到 ep06。
  • 不复述系统层基础:protobuf 结构属 ep07、data source 属 ep08、SQL 调优属 ep09、FrameTimeline 判据属 ep10;本篇只用 ep10 的判据定位是哪一帧。
  • 不做同帧印证:印证口径是 "方向一致",理由见 7.4 节。
  • 不做 Android 真机现抓:本机 adb devices 为空,六份 trace 全是官方测试数据。
  • 不把代理指标说成真指标:桌面 gpu_util_pct 来自 nvidia-smi 的 utilization.gpu 0.5 s 轮询,是"GPU 有活动的采样占比",不等于 SM 占用率,本篇不简写成"GPU 利用率";API 层拿不到硬件计数器时给的"开销量级"也只是代理指标。

最后提一句题图:它截的就是官方文档里那个"GPU 是一条独立轨道、频率与内存计数器挂在它下面"的画面[^ext1]。后面每一章要取的数,都是这条轨道上的数。


一、一条链路的全貌:六步与两个落点

下面我们开始拆这条链路。 这一章不给结论,只做一件事:把"我们怎么查 GPU 瓶颈"这件事,切成六个有先后、有输入输出、有失败信号的步骤。后面每一章都是这六步里某一步的展开。

1.1 六步链路:症状 → 判方向 → API 层 → 系统层 → 印证 → 结论

先看看六步各自的输入、输出与"走不通时是什么样":

步 动作 输入 输出 走不通的信号
1 症状定量 复现步骤 + 帧时间 帧时间与帧预算的差 复现不了,帧时间抖得没有分布
2 判定方向 帧时间 + GPU 侧忙闲指标 三分支之一:GPU 受限 / GPU 空转 / 数据不足 指标全是 0 或全是缺省值
3 API 层取数 一份可回放的抓帧文件 帧内事件条数、draw 数、API 耗时 Top 项 抓帧文件打不开或版本不匹配
4 系统层取数 一份含 GPU 计数器的 trace 频率轨、内存轨、工作周期 表存在但 0 行
5 交叉印证 3 与 4 两份输出 方向一致 / 不一致 / 量级不可比 两份证据不同设备,只能比方向
6 结论落点 印证结果 优化动作 + 该动作的验收指标 结论无法用量化指标验收

这张表里最容易被跳过的是第 5 步。 多数人的实际流程是"第 3 步拿到一堆 draw call → 凭经验改 → 再抓一次看帧时间有没有变"。这条路走得通,但把"印证"退化成"试错",代价是每次改动都要重跑一遍完整流程。第 5 步的意义,是在改代码之前先用两条独立证据把方向锁死。

1.2 为什么是"两个落点"而不是"两个工具"

先看看措辞。说"两个工具"会把注意力引到"RenderDoc 与 Perfetto 谁更强",那是错的问法;本篇用的是两个落点:一个症状,两个可以从不同侧面回答它的位置。

  • API 层落点:手上有一份可回放的帧。它的强项是"逐事件"——能数清这一帧提交了多少次绘制、每次用什么资源、哪些 API 调用最费时间;它的弱项是"看不到整机",帧与帧之间的调度、系统其它进程的干扰,一概不知。
  • 系统层落点:手上有一段时间的整机视图。它的强项是"看方向"——GPU 频率、GPU 内存、工作周期、CPU 调度都在同一条时间轴上;它的弱项是"粒度粗":至多告诉你"这段时间 GPU 有活",不会告诉你"第 37 个 draw call 有问题"。

这个分工不是我们定的,是官方推荐的。 Android 官方《分析和优化游戏性能》把流程拆成五步:第 2 步"CPU 和 GPU 绑定检查"列的是 Perfetto,第 4 步"GPU 优化"列的是 RenderDoc[^a1]。也就是说,"先用系统层看方向、再用 API 层看细节"就是官方顺序。同一页还给了两条可当判据的原文:"如果 GPU 完成时间超过帧时间限制(例如 25 毫秒),则游戏会受到 GPU 限制"、"受 GPU 限制:GPU 使用率高于 90%"——它们是第二章判定树的骨架。

另一条旁证来自 AGI。 Android 官方的系统性能分析概览页写得很直白:系统分析器建立在 Perfetto 跟踪系统之上[^a2];同一页对 GPU 计数器有一句关键限定——"计数器特定于硬件",轮询速率也因计数器类型而异。这四个字正是本机拿不到 NVIDIA 计数器的语境。

1.3 六步落成一条可跑的命令链

概念讲完,落到一次真实动作上。下面这段是本篇的固定动作序,每一步产出一个文件,后面每一章的查询都读这些文件。它是本章的落地动作,不是判据:

#-----------------------------------【六步链路:一次把两份证据都取出来】-----------------------------------
# 描述:左腿(API 层)从 .rdc 取帧内统计,右腿(系统层)从 trace 取 GPU 忙闲
# 实测:本机 RenderDoc v1.29(C:\Program Files\RenderDoc\)+ trace_processor_shell v58.2-add693d8b
#--------------------------------------------------------------------------------------------------------
#【1】第 3 步·API 层:不打开界面,先把帧内事件与 API 耗时抽成 JSON
renderdoccmd convert -f occlusion_capture.rdc -o occlusion_chrome.json -c chrome.json

#【2】第 3 步·API 层:导出捕获帧的画面,作为"证据长什么样"的底图
renderdoccmd thumb -f occlusion_capture.rdc -o frame_thumb.png

#【3】第 3 步·API 层:走脚本入口,一次拿回 draw 树、纹理与计数器枚举
qrenderdoc.exe --ui-python api_views.py occlusion_capture.rdc

#【4】第 4 步·系统层:频率轨——升频与降频的边界就是"有活/没活"的边界
trace_processor_shell.exe query android_startup_real.perfetto_trace "<SQL 见 5.5 节>"

#【5】第 4 步·系统层:内存轨——注意单位是字节,不是带宽
trace_processor_shell.exe query api34_startup_cold.perfetto-trace "<SQL 见 5.5 节>"

#【6】第 5 步·交叉印证:把【1】的微秒级量级与【4】【5】的忙闲方向并排看(口径见第六章)

下面对【1】【3】【6】特别说明。【1】最省事:只读文件、不开界面,产物是 JSON,能直接进脚本二次统计,代价是只给顺序与条数,不给 GPU 内部计数。【3】能力更强(draw 树、纹理导出、计数器枚举),但跑完不退出——本机实测:JSON 已落盘,30 秒后 qrenderdoc 进程仍在,脚本化必须自行收尾。【6】最弱,它比的是量级与方向,不是同一帧的因果;为什么只能这样比,第六章给三条实测、第七章给一条官方自陈。

1.4 本篇的印证是"方向级",不是"同帧级"

这一点必须在链路全貌里先说清楚,否则后面每一章的结论都会被过度解读。

理想形态是"同一帧因果链":一份 trace 里同时含 host 侧 API 调用与 GPU 侧工作,用 event id 把它们连起来,于是"这个 API 调用导致了那段 GPU 工作"是一条等值关系。这个形态不是空想——Perfetto 官方就给了两套机制:GpuRenderStageEvent 的 event_wait_ids 会被 trace processor 用来在相关联的 GPU 切片之间画 flow 箭头;TrackEvent 的 GpuCorrelation 扩展带两个字段 render_stage_submission_event_ids 与 render_stage_wait_event_ids,官方说它的用途是"把 host 侧 API 调用(例如 cudaLaunchKernel、cudaMemcpyAsync)与对应的 GPU 工作连起来"[^p1]。

但本机拿不到这个理想形态。 官方给的示例是 CUDA 语境;图形侧要拿到 gpu_render_stage 数据需要设备与采集配置同时到位,而本机六份 trace 上这张表零命中——我们另行逐条试过 INCLUDE PERFETTO MODULE android.gpu.*(通配可成功,但只建出三张 android_gpu_* 表)与 gpu.*(直接报 Package not found),它不由任何 INCLUDE 创建,是随输入数据存废的表(第五章给实测)。于是本篇退到第二档:

六步链路总图:从症状进来,经判定方向分成 API 层与系统层两条腿取数,在"交叉印证"处只比方向与量级,最后落到三类结论
图 11-1六步链路总图:症状 → 判定方向 → API 层取数 → 系统层取数 → 交叉印证 → 结论落点;印证处标注"只比方向与量级,不比同一帧"@ 本系列绘制

图上那道虚线的位置就是本篇的诚实边界。 它画在"交叉印证"这一步上:两条腿可以各自站住,但它们之间的连接是方向级的,不是等值级的——不能靠画一根箭头糊过去。

1.5 本章收在哪里

第一章走完,手里该有三件事:一条顺序不能颠倒的六步动线、两个落点的分工(官方顺序是系统层在前、API 层在后)、本篇的印证只到方向级。下一章先把第 2 步做扎实。

本章对应的官方依据:CPU/GPU 绑定检查与 25 ms / 90% / 15% 三条判据见 Android 官方《分析和优化游戏性能》(镜像站,.com 直链本机不可达);系统分析器与 GPU 计数器口径见 AGI 系统性能分析概览;两套跨层对齐机制见 Perfetto GPU 数据源页。


二、判定方向:GPU 忙、GPU 等、还是帧里根本没活

下面我们开始走第 2 步。 这一步不碰你的代码,也不打开抓帧文件,它只回答一个问题:这台机器现在缺的是 GPU 的算力,还是 GPU 之外的东西。 这一步做错,后面所有深挖都会选错工具。

2.1 三分支判定树与三条官方阈值

先看看判定树的形状。官方给的两个方向各有一个数,我们在此基础上补一个"数据不足"出口:

三分支判定树:GPU 受限支以"GPU 完成时间超过帧时间限制"与"GPU 使用率高于 90%"为判据,CPU 受限支以"CPU 归一化使用率高于 15% 且 GPU 使用率与 F
图 11-2三分支判定树与三条官方阈值:GPU 受限 / CPU 受限 / 数据不足,阈值各自标注语境边界@ 本系列绘制
分支 官方判据原文 该去哪个落点
GPU 受限 "如果 GPU 完成时间超过帧时间限制(例如 25 毫秒),则游戏会受到 GPU 限制";"受 GPU 限制:GPU 使用率高于 90%" 先 API 层:看每 event 的 GPU 侧开销、纹理规模与绘制次数
CPU 受限 "CPU(已归一化)使用率高于 15%,但 GPU 使用率和 FPS 较低";"如果 FPS 和 GPU 使用率较低,而 CPU 使用率超过 15%,则可认为这种情况属于 CPU 瓶颈" 先 API 层:看 CPU 侧调用与提交节奏,尤其是创建类与同步类调用
数据不足 本期新增:两个方向的指标全为缺省值或 0 行 先补齐数据,再回到分支一或二

这张表里最容易误读的是"CPU 受限"那一行的 15%。 它不是在说"15% 的 CPU 就很忙",而是把"高 CPU、低 GPU、低 FPS"三个条件放在一起描述一种不均衡:CPU 在忙、GPU 在等、帧出不来。只截取其中一个数当判据,是这张表最常见的用法错误。

第三条分支是我们在实战里补的。 官方的两条判据都假定"你能看到那两个数";而本机实测过一种情况:六份 trace 里有一份的 GPU 计数器轨道与 GPU 轨道双双为 0(webview_jank.pb:gpu_counter_track 0 行、gpu_track 0 行,却有 1031 条帧记录)。这份数据不是"GPU 没活",是"这份 trace 根本没采到 GPU 侧"。 把它读成"GPU 空闲",会直接导出反向结论,所以判定树必须留一个"数据不足"的出口。

2.2 阈值不可跨平台照搬:25 ms 是移动语境,桌面 60 FPS 是 16.67 ms

这一节是本章的诚实标注,也是我们吃过亏的地方。

官方那句"GPU 完成时间超过帧时间限制(例如 25 毫秒)",出处页面的语境是移动游戏,而 25 ms 对应的帧率大约是 40 FPS——它是在给移动端留余量:手机要控功耗、要控温度、屏幕刷新率也常常不是 60 Hz。换到桌面,60 FPS 对应的帧预算就是 16.67 ms(1000 ÷ 60),90 Hz 是 11.11 ms,120 Hz 是 8.33 ms。 两个数字放在一起差 8.33 ms,接近一倍。

语境 帧预算 25 ms 意味着什么
移动游戏(官方 25 ms 那句所在页面) 依设备刷新率浮动,常按 33.3 ms / 40 FPS 留余量 正好卡在"刚刚超预算"的量级
桌面 60 FPS 16.67 ms 25 ms 已经超预算 50%,是明确的掉帧
桌面 90 Hz / 120 Hz 11.11 ms / 8.33 ms 25 ms 是超预算 2 到 3 倍

结论:25 ms 可以当"经验性观察阈值",绝不能当"桌面 60 FPS 的判据"。 本篇凡提到 25 ms 一律连语境引,凡对桌面帧下判断一律用 16.67 ms——把 25 ms 当红线的看板,在 60 Hz 桌面上会漏掉一半以上的超预算帧。

2.3 桌面侧的第一份真值:utilization.gpu 不是 SM 占用率

言归正传,方向要判就得有数。桌面侧最容易拿到的数是显卡厂商工具给的"GPU 使用率",本机也一样:我们的自动化性能测试工程 perf-auto 用 nvidia-smi --query-gpu=utilization.gpu,power.draw,temperature.gpu 做 0.5 秒轮询,跑完一轮 F1 场景后落下一份时序。

本机桌面采样时序:gpu_util_pct 在 27 个采样点上中位数 19.0%、最大 28.0%、p90 为 23.0,功率中位数 19.87 瓦、温度中位数 71.0 摄氏度
图 11-3本机桌面采样真值:gpu_util_pct 中位 19.0% / 最大 28.0%、功率中位 19.87 W、温度中位 71.0 ℃,27 个样本@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)

这张图先要按住一处口径,再看数字。 utilization.gpu 这个字段的含义是"在过去一段时间内,GPU 有活动的采样占比"——它是活动占比,不是 SM 占用率,也不是"SM 里有多少 warp 在跑"。一个只用了 2 个 draw call 的小场景和一个满负载的计算任务,都可能把它推到高位——因为它是按"有没有活动"统计的。所以本篇一律写全称:nvidia-smi 的 utilization.gpu,不简写成"GPU 利用率"。[^src8]

再看数字本身,它给出的方向很清楚: 27 个样本的中位数是 19.0%、最大值 28.0%、p90 是 23.0%,功率中位数只有 19.87 W(这块 RTX 3060 Laptop 的整卡功耗预算远高于此),温度中位数 71.0 ℃。三个量一起看,指向同一个结论:这一轮跑的场景里,GPU 大部分时间不在干活。 这就是后文"GPU 空转"方向的第一个证据。

2.4 公式:把稀疏采样变成区间,再算时间加权平均

先来看看这一步换算:从"活动占比"走到"时间轴上的占用",官方给了标准写法。 采样点是稀疏的,两个采样点之间只知道自己"落在某个值上",不能直接把采样值求平均——那样会把时长信息丢掉。正确做法是先把采样点展开成带时长的区间,再按区间时长加权:

时间加权平均利用率 = Σ(u.value × ii.dur) / k.dur

其中:

  • $u.value$ 是该采样区间上的计数器取值(这里是活动占比或占用率);
  • $ii.dur$ 是该区间的时长,也就是"这个值持续了多久";
  • $k.dur$ 是被考察对象(例如某次 kernel、某段帧)的总时长;
  • 分子是"占用 × 时长"的累加,量纲是"占用秒",除以总时长后回到"占用"。

这个公式解决的是"稀疏样本不能直接平均": 把采样点当等权样本求平均,数字与采样时刻绑定、不可复现;按区间时长加权才是"这段时间里平均占了多少"。官方示例的结果就是这个量级:matmul_bwd_kernel 时长 180000 ns、时间加权平均利用率 78.27[^p2]。

参数逐项释义:value 的量纲取决于计数器本身(百分比、字节、赫兹),必须先看计数描述符再解释;dur 的单位是纳秒,换算成毫秒要除以 $10^6$;区间端点用 ts 与 ts + dur 表达,与其它时间区间口径一致——因为"把两个不同来源的区间放在一起比"是第六章的全部动作。

2.5 用官方范式把利用率算出来

落到查询上。下面是官方示例的骨架,它借用了官方的两个模块:counters.intervals 与 intervals.intersect(k / u / ugpu 是官方示例里"被考察对象"与"计数器区间"的占位别名)。

-------------------------------------【时间加权平均利用率:官方范式】---------------------------------
-- 描述:把稀疏 counter 样本展开成 (ts, dur, value) 区间,再与目标区间求交、按时长加权
-- 依据:perfetto.dev/docs/data-sources/gpu 的官方示例查询(counters.intervals + intervals.intersect)
----------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE counters.intervals;              --【1】提供把稀疏样本展开成区间的能力
INCLUDE PERFETTO MODULE intervals.intersect;             --【2】提供区间求交,把利用率裁到目标区间上

SELECT
  k.name                                        AS kernel,      --【3】被考察对象的名字
  t.name                                        AS track,       --【4】数据来自哪条计数器轨道
  k.dur                                         AS dur_ns,
  ROUND(SUM(u.value * ii.dur) / k.dur, 2)       AS avg_util     --【5】时间加权平均利用率
FROM _interval_intersect!(
      (counter_leading_intervals!((SELECT ts, dur, value, track_id FROM counter)), (k)), (ugpu)
    ) AS ii
JOIN k ON ii.k = k.id
JOIN u ON ii.u = u.id
JOIN gpu_counter_track AS t ON u.track_id = t.id
ORDER BY avg_util DESC
LIMIT 10;

下面对【1】【5】特别说明。【1】就是"先展开成区间":把稀疏采样变成 (ts, dur, value) 区间,不做这步,后面的加权无从谈起。【5】是本段的目的:分母用被考察对象自己的时长——否则一个只跑 3 ms 的 kernel 会被 10 秒的 trace 稀释。分子分母必须来自同一个时间窗。

这段骨架在本机直跑会停在 no such table: k——不是语法错,是占位别名没定义;即便按官方示例把别名补齐(gpu_slice + 一条名为 Utilization 的 GPU 计数器轨道),本机六份 trace 上也取不到数:gpu_slice 恒 0 行,gpu_counter_track 里也没有 Utilization 这条轨道——本机实测只有 gpufreq 与 GPU Memory 两条(第五章给逐份实测)。本篇保留这段查询的用意是"范式可复用":换一台有真实 GPU 计数器的机器,这段写法通用。

2.6 本章收在哪里

第二章走完,判定方向该有能力了:三分支判定树、三条阈值各自带语境(25 ms 与 16.67 ms 不可互换)、桌面第一份真值指向"GPU 大部分时间没在干活"、时间加权平均利用率的官方写法与分母纪律。下一章进入 API 层。

本章对应的官方依据:三条阈值与工具分工见 Android 官方《分析和优化游戏性能》(镜像站);时间加权平均利用率的范式与示例见 Perfetto GPU 数据源页;counters.intervals 与 intervals.intersect 两个模块的说明见 标准库文档索引。


三、API 层取数:不打开界面,能取到哪三样

下面我们进入第 3 步。 这一章的立场是"命令行优先":能用命令拿的就不开界面——命令行产物能进脚本、能存档、能二次统计。本章取三样:帧内统计、Top API 耗时分布、一张"证据长什么样"的底图。

3.1 三件套:输入、产物与能力边界

先来看看这三个入口的分工,它们随 RenderDoc 一起装好,不需要写代码就能跑:

API 层三件套对照:renderdoccmd convert 只读文件产出 JSON 形式的帧内事件序列,renderdoccmd thumb 产出捕获帧画面,qrenderdo
图 11-4API 层三件套的输入、产物与能力边界:convert / thumb / --python 三条入口各自拿到什么、都拿不到什么@ 本系列绘制
入口 输入 产物 能回答什么 拿不到什么
renderdoccmd convert -c chrome.json 一份 .rdc 帧内事件序列(B/E 配对、每事件的 API 名与耗时) 这一帧提交了多少事、时间花在哪些 API 上 GPU 内部计数、着色器耗时
renderdoccmd thumb 一份 .rdc 捕获帧的 PNG 画面 这一帧画出来是什么样(复现底图) 任何时间信息
qrenderdoc --ui-python 一份 .rdc + 一段脚本 draw 树、纹理导出、计数器枚举等结构化 JSON 场景结构、资源尺寸、计数器枚举结果 厂商硬件计数器的取值(本机那一路直接报错,见第四章)

这张表最该记住的是最后一列。 三个入口都没有"GPU 内部计数器读数"这一项——这不是我们没找到开关,是这台机器上没有那条路。第四章会给出目录级的反证(plugins/ 下没有 nv),并说明官方文档里那三种硬件计数器来源为什么在本机一条都不成立。

3.2 帧内统计:这一帧里几乎没有渲染工作

先看看最粗的那一层。convert 的产物里,每个 API 调用都有开始与结束两条记录,配对之后就能数出"这一帧提交了多少个事件、多少个是绘制与派发、所有 API 调用加起来花了多久"。

本机四份抓帧的帧内统计:B/E 配对 47 / 38 / 33 / 49 条,draw 与 dispatch 合计 1 / 2 / 1 / 2 个,帧内 API 总耗时 12.0
图 11-5本机四份抓帧的帧内统计:B/E 配对 47 / 38 / 33 / 49 条、draw 与 dispatch 合计 1 / 2 / 1 / 2 个、帧内 API 总耗时 12.0 / 14.4 / 10.1 / 25.1 µs@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)

先说口径。 这四组数来自四份桌面 D3D11 抓帧(本机全盘共六份 .rdc,两份 md5 相同、即同一次抓帧;本篇固定取四份逐格对照);"帧内 API 总耗时"是帧内所有 API 调用耗时的累加,量纲是微秒不是帧时间——它量的是 CPU 调用图形 API 的时间,不含 GPU 执行时间与应用的逻辑时间。

再看数字,方向很清楚:最大的一份也只有 25.1 µs。 对比桌面 60 FPS 的帧预算 16.67 ms,25.1 µs 占 0.15%;另外三份分别占 0.07%、0.09%、0.06%。绘制与派发加起来是 1 到 2 个——这不是"有很多 draw call 要优化"的场景,是"帧里根本没排上活"的场景。

这一步的结论是方向性的。 它说明"从 API 层开销入手找 GPU 瓶颈"在这四份抓帧上没有意义;但不能推出"这台机器的 GPU 不是瓶颈"——抓帧的是自造演示程序,不是那份掉帧的现场。

3.3 Top API 耗时:三项全是 Create*,与 NVIDIA 点名的观察点同类

先来看看排序结果,它比"总量很小"更有意思:

本机四份抓帧的 Top API 耗时分布:耗时前三项全是 CreateTexture2D / CreateBuffer / CreateQuery 这类创建类调用,另一份抓帧上 C
图 11-6本机四份抓帧的 Top API 耗时分布:前三项全是 Create* 与 CreateQuery,另一份上 CreateQuery ×2 合计 9.0 µs@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)

这件事值得单独说,因为它不是"我们自己挑出来的观察点"。 NVIDIA 那篇给出 Peak-Performance-Percentage 方法论的官方博客里,明确列出了 DX11 要盯的 CPU 调用:Flush{,1}、Map、UpdateSubresource{,1},以及"任何 Create 或 Release 调用"(原文是 "Any Create or Release calls")[^n1]。本机这份 Top 三项全是 CreateTexture2D、CreateBuffer、CreateQuery——与 NVIDIA 点名的清单正好同类。[^src2]

这意味着两件事。 一,在只画 1 到 2 个 draw 的小场景里 Create* 还能霸榜,说明它们是每帧重复的,于是"把资源创建移出帧循环"是这个场景第一顺位的动作。二,这类调用的成本不在 GPU 侧,创建纹理与缓冲主要花在驱动与内存分配上,对策是复用(对象池),不是减少像素——看到 Create* 霸榜却去优化着色器,是用错了力气。

3.4 证据长什么样:一张帧画面与一次界面四区对照

上面两组都是数字。第 3 步还该留下一样东西:一张能被人眼复核的底图——它不算证据,但它是"我们确实抓到了这一帧"的凭据。[^src1]

本机 renderdoccmd thumb 从抓帧导出的帧画面:画面中央是一个立方体,背景为渐变底色,它就是第 3 步留下的可复核底图
图 11-7renderdoccmd thumb 从抓帧导出的捕获帧画面——第 3 步留下的可复核底图@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)

这张图的用处很小,但不可替代。 它证明"抓帧抓到了内容"——这件事经常被跳过:如果抓在了帧外,后面的统计就会全是"0 个 draw、0 微秒",而你可能会认真地解释这个 0。看到画面,才知道这个 0 是真的 0。

同一份抓帧还能给出一张界面四区对照图,用来把数字与 UI 的位置对应起来:[^src3]

本机 qrenderdoc 界面四区标注:① 左侧 Event Browser 事件树 ② 中间 Mesh 视图 ③ 下方 Timeline 标记带 ④ 右侧 API Inspec
图 11-8qrenderdoc 界面的四区标注:事件树 / Mesh 视图 / Timeline 标记带 / API Inspector——数字与界面位置的对照@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)

这张图的框是"探测出来再验过"的,不是手画的:界面截图脚本按 Qt 布局信息探出各面板矩形,再逐像素校验边界才画框。这四个面板各自的机制与读法属 ep01 到 ep06,本篇只当它们是"第 3 步的现场照片"。

3.5 把这一章落成一条命令

下面是本章的落地动作:从一份 .rdc 出发,一次把帧内统计与自定义二次统计都取出来。[^src4]

#-----------------------------------【API 层三件套:从 .rdc 到可统计的 JSON】-------------------------------
# 描述:先换壳(.rdc → chrome.json),再做自己的二次统计(事件条数 / draw 数 / 耗时 Top 项)
# 实测:本机 RenderDoc v1.29;产物落在 perf-auto 的 artifacts 目录下,可重复跑
#------------------------------------------------------------------------------------------------------------
#【1】换壳:只读文件、不开界面,产物是 JSON,含每个 API 调用的开始与结束时刻
renderdoccmd convert -f occlusion_capture.rdc -o occlusion_chrome.json -c chrome.json

#【2】事件条数:B/E 配对的数量就是"这一帧提交了多少件事"的度量
python -c "import json;d=json.load(open('occlusion_chrome.json',encoding='utf-8'));e=d['traceEvents'];print(sum(1 for x in e if x.get('ph')=='B'))"

#【3】绘制与派发:把 draw / dispatch 类调用单独数出来,避免与资源创建混在一起
python -c "import json;d=json.load(open('occlusion_chrome.json',encoding='utf-8'));e=d['traceEvents'];print(sum(1 for x in e if x.get('ph')=='B' and ('Draw' in x.get('name','') or 'Dispatch' in x.get('name',''))))"

#【4】耗时 Top 项:按 duration 排序取前三,直接看"时间花在哪些 API 上"
python -c "import json;d=json.load(open('occlusion_chrome.json',encoding='utf-8'));e=[x for x in d['traceEvents'] if 'dur' in x];[print(x['name'],x['dur']) for x in sorted(e,key=lambda x:-x['dur'])[:3]]"

下面对【1】【4】特别说明。【1】的产物是纯数据,不进 UI、不依赖桌面会话,能塞进 CI;代价是拿不到 GPU 侧任何东西——chrome.json 里没有一列是 GPU 计数器。【4】是先排序、再归因:Top 三项一打印,"优化方向"就从"凭感觉"变成"看排名",本机这四份的排名就是三项 Create*。

3.6 本章收在哪里

第三章走完,API 层三样东西到手:三件套的分工与共同边界、帧内统计的四个真值、Top API 全是创建类调用。但这一章有一半天花板没说透——我们一直在用"调用耗时"这类可见量代替真正的 GPU 计数器,下一章说开。

本章对应的官方依据:三件套的命令行入口与参数见 RenderDoc 命令行参考;性能计数器面板的能力边界见 Performance Counter Viewer;Create* 与 Flush / Map / UpdateSubresource 这组观察点见 NVIDIA Peak-Performance-Percentage 方法论(厂商官方博客,本篇只引这一条观察点)。


四、API 层的天花板:菜单里有,不等于你采得到

下面我们说说这一篇最该讲清楚的缺口。 前面三章一直在讲 API 层"能取到什么";这一章讲"取不到什么、为什么取不到"。这件事有两个用处:一是它决定第三章那条腿只能给代理指标;二是它解释了为什么本篇的印证只能做到方向级——如果每一层都能拿到完整数据,我们本可以走得更远。

4.1 官方面板的标准动作:选—采—表—CSV

先看看官方设计的那条路。RenderDoc 的性能计数器面板(Performance Counter Viewer)把"采 GPU 计数器"拆成四步,官方文档的措辞很直白:它提供一个"非常简单的方式,用来在整份抓帧的所有事件上取回 GPU 计数器的结果"[^r1]。

RenderDoc 官方实景:性能计数器结果表——按 EID 排序的大表,每个事件一行,每一列是一个计数器,列可单独排序,右上角有 CSV 导出入口
图 11-9官方性能计数器结果表实景:逐事件一行、逐计数器一列的大表,"选—采—表—CSV"这条动作的终点@ RenderDoc 官方文档(MIT)
步 官方动作 官方原文要点 对我们的用处
1 点 Capture Counters 弹出对话框,列出所有可用计数器 先看清这台机器上有哪些计数器可选
2 在对话框里勾选 左侧树状分组、每个计数器带描述、每个类别也有勾选框;Save / Load 可把采样集合存成配置文件 "先选什么"有了可复现的落点
3 点 Sample Counters RenderDoc 会遍历每个事件、取回所选计数器的全部数据,并以一张大表显示;默认按 EID 排序、每列可单独排序 与 ep04 的事件浏览器天然对齐——行号就是 EID
4 点 save 把结果存成 CSV "可复核"的证据形态:CSV 而不是截图

第 3 步与第 4 步是最要紧的两句。 第 3 步说明"逐事件"是这套面板的粒度——它给的是与事件树同构的表,不是总数;第 4 步说明官方认可的证据形态是可再计算的文件。[^ext2]

第 2 步的截图长这样,它是"先选什么"那一步的现场:[^ext3]

RenderDoc 官方实景:计数器选择对话框——左侧树状分组列出可用计数器,每个计数器带一段描述,类别与单项都可勾选,对话框底部是 Save 与 Load 两个按钮
图 11-10官方计数器选择对话框:树状分组 + 逐项描述 + 类别勾选 + Save/Load 存采样集合@ RenderDoc 官方文档(MIT)

注意对话框右上角那个 Save 按钮:它说明"要采哪些计数器"应该固化成配置。 采集集合不固化,两次跑的数据就不可比——这与 ep09、ep10 反复强调的"口径要一致"是同一件事。

4.2 三种硬件计数器来源:本机一条都不成立

三种硬件计数器来源与本机可得性:AMD GPA 随 RenderDoc 一起分发、只能用于支持的 AMD 显卡
图 11-11三种硬件计数器来源、安装路径与本机的目录级反证:三条路在本机一条都不成立@ 本系列绘制
来源 官方说明要点 安装路径 本机
AMD GPA RenderDoc 自带该库支持,仅限受支持的 AMD 显卡 随包安装(本机有 7 个 GPUPerfAPI*.dll) 不成立(显卡是 NVIDIA)
NvPerfKit 许可证禁止再分发,故不随包 plugins/nv/counters/x64,放 NvPmApi.Core.dll 不成立(目录不存在)
Nsight Perf SDK 同样不能随包分发 renderdoc/plugins/nv,放 nvperf_grfx_host.dll 不成立
反证(实测) plugins/ 下只有 amd、android、spirv,没有 nv — —

"许可证禁止再分发"是整个缺口的技术根因,不是配置失误。 要把一个不能随包分发的库塞进安装目录,只能由使用者自己做——官方把安装路径写得那么具体,正说明这条路需要人工介入。

版本线上还有一个旁证: RenderDoc 修过 "D3D12:当安装了 NVIDIA 计数器插件时…浏览事件耗时崩溃",也修过 "D3D11:NV 计数器在事件很多时可能取不回结果"。两条缺陷都以"安装了…插件"为前提,说明它是需额外安装的第三方件,不是内置功能。

4.3 本机实测:枚举 14 条,13 个通用计数器有值、厂商那一条直接报错

好了,文档说完看实测。本机走脚本入口跑了计数器的两个动作——先枚举、再取值:

本机计数器实测对照:EnumerateCounters 枚举出 14 条(13 个通用计数器 + 1 条名字写着"找不到 Nsight Perf SDK 库"的占位项),FetchCounters 逐 event 返回 35 行,event 22 上 Samples Passed 与 PS Invocations 都是 340,472
图 11-12本机计数器实测:枚举 14 条 = 13 个通用计数器(GPU Duration、Input Vertices Read、Input Primitives、GS Primitives、Rasterizer Invocations、Rasterized Primitives、Samples Passed、VS/HS/DS/GS/PS/CS Invocations)+ 1 条占位项(名字即 ERROR: Could not find Nsight Perf SDK library);FetchCounters 逐 event 返回——13 个具名计数器 × 5 个根动作 = 65 行(只取其中 7 个具名则为 35 行),event 22 上 Samples Passed / PS Invocations 都是 340,472@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)
动作 实测结果
EnumerateCounters() 返回 14 条:13 个具名计数器 + 1 条占位项,名字直接写着 ERROR: Could not find Nsight Perf SDK library
FetchCounters() 逐 event 返回——每 event、每计数器一行。本机那份抓帧有 5 个根动作(EID 3 / 4 / 22 / 30 / 32):取 7 个具名计数器 = 35 行,取全部 13 个 = 65 行;SetFrameEvent 不改变这个粒度
具名计数器确实有值 event 22(第一个 DrawIndexed)上 Samples Passed = 340,472、PS Invocations = 340,472、Input Vertices Read = 6、Input Primitives = 2;event 30(第二个 DrawIndexed)上 VS Invocations = 170、Input Primitives = 96
取值时的附加坑 CounterValue 不能直接 float():.d / .u64 / .u32 是同一段字节的三种解释,读错字段会拿到"实数"。Samples Passed 的单位是 Absolute,只能读 .u64/.u32;GPU Duration 是 Seconds,只能读 .d
views.json 的 counters 段为何 7 项全 0.0 不是"采不到",是记账方式:旧脚本把逐 event 的结果按计数器名装进 dict,而 FetchCounters 按 event 顺序返回,最后一个 event(EID 32,Present)的全 0 值把前面 event 的非 0 读数覆盖掉了。用同一份抓帧、同一段逻辑重放已复现:返回 35 行,dict 收尾仍是 7 项全 0.0

这张表最要紧的是第一行与第三行的对照:枚举成功、通用计数器也采得到,缺的恰恰是厂商那一路。 那第 14 条占位项自己把话说完了——ERROR: Could not find Nsight Perf SDK library;能出数的那 13 个是 RenderDoc 借 D3D11 的 query 就能算出来的通用/管线统计量,而厂商硬件计数器(sm__*、GPA 那一类)一个都没进来。这与 4.2 节的目录反证互为因果:没有 nv,就没有 Nsight Perf SDK 库,也就没有那些计数器。

同一份脚本还留下三组"抓帧抓到了内容"的旁证:根动作 5 个、其中绘制 2 个;纹理 3 张(1264×681 的 B8G8R8A8_UNORM、1264×681 的 D24S8 深度、2×2 的 B8G8R8A8_SRGB),导出成 PNG 分别是 35,002 / 34,984 / 77 字节;有内容、有深度、有 draw,Samples Passed 也给出 340,472 的真实读数——这台机器缺的不是"能不能采",是"厂商那一支没装"。

4.4 于是 API 层那条腿只能给"可见开销量级"

好了,这一节是本篇的第二个诚实标注,写在明处。

第三章那条腿给出的所有数字——事件条数、draw 个数、API 耗时、Top 项排名——都不是 GPU 内部计数器的读数。它们能看见的是顺序、条数、CPU 侧调用耗时这三类可见开销。所以本篇对 API 层那条腿的定位是:[^src5]

API 层给的不是"GPU 忙不忙",而是"这一帧的可见开销量级"。

"量级"这个词是刻意的。 它意味着这些数字的用法是跨数量级比较:25.1 µs 与 16.67 ms 差三个数量级,足以支撑"CPU 侧开销不可能是帧时间的大头";但不足以支撑"GPU 侧开销是 X 毫秒"这类定量判断——后者必须来自硬件计数器。本篇给不出的是厂商硬件计数器那张逐事件大表(枚举里的第 14 条就是它缺席的证据),能拿到的只有 13 个通用/管线统计量;"路为什么没通"可以被写成可复现的检查项。

4.5 把这一章落成一段脚本

下面是本机跑过的检查动作:先问"有哪些计数器",再问"取回来是什么值":

#-----------------------------------【计数器两步:先枚举,再取值】-------------------------------------------
# 描述:用 qrenderdoc 的脚本入口跑一段探针,分别打印枚举结果与取值结果
# 实测:本机枚举 14 条、FetchCounters 逐 event 返回 35 行,厂商那一条报 "找不到 Nsight Perf SDK 库"(根因:plugins/ 下无 nv)
#------------------------------------------------------------------------------------------------------------
#【1】脚本入口:--ui-python 在打开主界面后执行,适合需要重放控制器的场景
qrenderdoc.exe --ui-python counters_probe.py occlusion_capture.rdc

#【2】脚本跑完进程不会自己退出:30 秒后 qrenderdoc 仍在,须自行结束进程
Stop-Process -Name qrenderdoc -ErrorAction SilentlyContinue

#【3】目录级反证:看这台机器上到底有没有 NVIDIA 的计数器插件
Get-ChildItem 'C:\Program Files\RenderDoc\plugins\' -Directory | Select-Object Name

#【4】AMD 那一支是随包来的:本机该目录下有 7 个 GPUPerfAPI 动态库(但显卡不是 AMD)
Get-ChildItem 'C:\Program Files\RenderDoc\plugins\amd\counters\'

下面对【1】【2】特别说明。【1】选 --ui-python 是因为它在打开主界面后执行,能拿到重放控制器之外的界面上下文;代价是同样不会在脚本结束时退出,所以【2】是必需收尾——实测:JSON 已落盘,30 秒后进程仍在。这两条写进模板,能省掉以后每一次"为什么 CI 卡住了"。

4.6 本章收在哪里

第四章走完,天花板说清楚了:官方面板的四步动作、三种硬件计数器来源全是厂商库且两条 NVIDIA 路径在本机落空、枚举 14 条而厂商那一路直接报错(通用计数器可采)、于是 API 层只能给"可见开销量级"这个代理指标。下一章换到系统层。

本章对应的官方依据:面板四步动作、三种硬件计数器来源与安装路径见 Performance Counter Viewer;CounterValue 的三字段取值与脚本入口见 RenderDoc Python API;版本修复记录见 RenderDoc Releases。


五、系统层取数:频率与内存这两条硬证据

下面我们换到另一条腿。 系统层不问你画了什么,它问的是在这段时间轴上,GPU 的状态如何变化。本章要拿的是两条硬证据:GPU 频率(升频↔有活、降频↔空闲)与 GPU 内存(谁占了显存)。这两条都不是我们发明的判读法,是官方数据源与标准库直接支撑的。

5.1 数据源矩阵与一个会静默失效的坑

先看看官方给的 GPU 数据源清单。这张表最该注意的是最后那句"精确名匹配":

Data Source 采集什么 一句话
gpu.counters 周期或插桩式的 GPU 计数器采样 "忙不忙"的主力,但硬件相关
gpu.renderstages GPU 渲染阶段与计算活动的时间线 想知道"时间花在哪个阶段"就要它
vulkan.memory_tracker Vulkan 内存分配与绑定 显存侧的下钻
gpu.log GPU 调试日志 排错用
linux.ftrace GPU 频率、内存总量、DRM 调度事件 本篇要用的两条都在这里

坑在这里:官方原文说"GPU 生产者通常用带硬件后缀的名字注册数据源,例如 gpu.counters.adreno 或 gpu.renderstages.mali,而跟踪服务使用精确名匹配"[^p3]。写错后缀不会报错,只会静默收不到数据——这是第七章陷阱矩阵的第一条。

5.2 标准库四个模块:真实列、依赖与本机可得性

数据采到了,还要有表能问。官方标准库在 android/gpu/ 下放了四个模块。这里先更正一处流传的说法:网络上(包括我们上一轮的研究笔记)常把这一族写成 gpu/* 模块——实测不存在这个目录;stdlib/gpu/ 的目录枚举与两个文件直链全是 404,真实存在的是 stdlib/android/gpu/,只有四个 SQL 文件加一个构建文件。

android/gpu 四模块分工与本机可得性:frequency 依赖 counter 表里名为 gpufreq 的轨道并展开成带时长的区间,memory 依赖 linux.me
图 11-13android/gpu/* 四模块的真实列、依赖与本机可得性:前两个有真值,后两个在本机 0 行@ 本系列绘制
模块 产出表(列从略) 数据来源 本机
android.gpu.frequency android_gpu_frequency gpufreq 轨道经 counter_leading_intervals! 展开成区间 有真值
android.gpu.memory android_gpu_memory_per_process 依赖 linux.memory.general,取名为 GPU Memory 的计数器 有真值
android.gpu.mali_power_state android_mali_gpu_power_state 轨道名 mali_gpu_power_state,注释称其"类比 CPU 的空闲态" 0 行(Mali 专用)
android.gpu.work_period android_gpu_work_period_track 来自 power/gpu_work_period,注释写明"该点通常只在部分设备上可用" 0 行

一句话:官方给了四个模块,本机只有前两个能出数。 后两个各返回 0 行——这不是"GPU 没工作",是数据源根本没采到东西。把它们读成"GPU 一直空闲"是最容易犯的错,这也是本篇坚持"0 行与 0 值分开写"的原因:前者是没采到,后者是采到了但为 0。

5.3 gpufreq 真值:六份 trace 的峰值与一条铁律

先来看看 frequency 模块的输入:名为 gpufreq 的计数器轨道。本机在六份官方 Android 测试 trace 上逐份跑过:

六份 trace 的 gpufreq 真值:峰值频率分别为 750,000 / 434,000 / 302,000 / 257,000 / 434,000 赫兹,另有一份 webv
图 11-14本机六份 trace 的 gpufreq 真值:峰值 750,000 / 434,000 / 302,000 / 257,000 / 434,000 Hz,webview_jank.pb 为 0 行;min 在六份上恒为 0(口径未定论,篇中只作观察)@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)
trace gpu_counter_track gpu_track gpufreq 峰值 (Hz) gpufreq 均值 (Hz,原始采样点等权) 轨道数
android_startup_real.perfetto_trace 18 9 750,000 330,306 2
wattson_jank_cuj.pb 8 31 434,000 171,250 2
webview_jank.pb 0 0 0 行 — 0
api34_startup_cold.perfetto-trace 14 5 302,000 151,133 2
api32_startup_warm.perfetto-trace 2 5 434,000 171,250 2
api31_startup_warm.perfetto-trace 1 0 257,000 114,222 1

这张表要读出三件事。 第一,频率是有档位的,峰值差近三倍(750,000 对 257,000 Hz)——升频说明有活、降频说明闲,把它当"有/没有"的布尔量会浪费信息量。第二,webview_jank.pb 是"数据不足"分支的实测来源:GPU 计数轨道与 GPU 轨道双双为 0,却有完整的帧记录——帧数据齐备而 GPU 数据完全缺失,现实中会同时发生。 第三,gpufreq 的最小值六份恒为 0,我们没有查到定论,所以只用峰值与均值,不对 0 做解释。

5.4 GPU Memory 真值:单位是字节,不是带宽

下面看第二条证据:显存。同一批 trace 上,GPU Memory 的峰值与落到进程的行数是:[^src6]

六份 trace 的 GPU Memory 真值:峰值 604,762,112 / 253,927,424 / 462,737,408 字节,对应轨道数 16 / 6 / 12、原
图 11-15本机 GPU Memory 真值:峰值 604,762,112 / 253,927,424 / 462,737,408 字节、轨道 16 / 6 / 12,落到进程后 6,200 / 1,213 / 416 行@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)
trace GPU Memory 峰值(字节) 轨道数 / 原始行数 落到进程的 android_gpu_memory_per_process 行数
android_startup_real.perfetto_trace 604,762,112 16 / 12,390 6,200
wattson_jank_cuj.pb 253,927,424 6 / 2,422 1,213
api34_startup_cold.perfetto-trace 462,737,408 12 / 823 416

这张表的第一条纪律:单位是字节。 604,762,112 字节约合 577 MiB——它是一个"占用量",不是一个"速率"。性能分析里最容易犯的错之一,就是把"显存占用"与"显存带宽"混着读,然后得出"带宽 577 MB,帧时间应该更短"这类没有出处的结论。"内存"≠"带宽",这两个量本篇各写各的。

第二条纪律:落到进程的行数远少于原始行数(12,390 对 6,200),差额是没有归属到进程的采样。"GPU 内存高"要落到人头上,必须看落到进程之后的那张表——它才回答"是哪个进程占的"。

5.5 把这两条证据问出来

下面这段查询是本章的落地动作,两条证据各一段,都在同一份 trace 上跑:[^src7]

-------------------------------------【频率轨:升频与降频的边界】-----------------------------------
-- 描述:取 gpufreq 的峰值与均值,用带时长的区间视图而不是原始采样点
-- 实测:android_startup_real.perfetto_trace 上 intervals 567、峰值 750,000 Hz、区间加权均值 326,422 Hz(5.3 节表里是原始采样点等权均值 330,306 Hz)
------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE android.gpu.frequency;            --【1】展开成 (ts, dur, gpu_freq) 区间

SELECT
  count()                       AS intervals,             --【2】区间段数
  max(gpu_freq)                 AS max_hz,                --【3】peak:有活时的最高档
  avg(gpu_freq)                 AS avg_hz,                --【4】均值:整段的平均档位
  min(gpu_freq)                 AS min_hz                 --【5】恒为 0,口径未定论,只作观察
FROM android_gpu_frequency;

-------------------------------------【内存轨:占用量与它的归属】---------------------------------
-- 描述:先取总量峰值,再看落到进程之后的行数与占用
-- 实测:android_startup_real.perfetto_trace 上落到进程 6,200 行、其 max(gpu_memory) = 264,990,720 字节(604,762,112 是原始 GPU Memory 轨道峰值,见 5.4 节)
------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE android.gpu.memory;               --【6】依赖 linux.memory.general

SELECT
  count()                       AS rows_total,
  max(gpu_memory)               AS max_bytes              --【7】单位是字节,不是带宽
FROM android_gpu_memory_per_process;

下面对【1】【7】特别说明。【1】是两模块的公共前提:它们取的不是某个时刻的采样值,而是"一段一段的区间"——频率是"这个档位持续了多久",内存是"这个占用量持续了多久",有 dur 才有时长加权的基础(回链 2.4 节)。【7】再次强调单位:gpu_memory 是字节。当带宽读会导出方向相反的动作——占用高要减资源,带宽高要减传输。

5.6 官方界面长什么样(以及为什么这里的图要标语境)

上面全是 SQL 结果。官方文档给了三张实景图,说明这些数据在 UI 上如何呈现:

Perfetto 官方实景:dev.perfetto.GpuByProcess 插件把 GPU 轨道按进程拆开,厂商计数器按进程归集——回答"是哪台进程在用 GPU"
图 11-16官方实景:dev.perfetto.GpuByProcess 把 GPU 轨道按进程拆开,厂商计数器按进程归集@ Perfetto 官方文档(Google,Apache-2.0)

另两张回答"GPU 工作量能否被量化"以及能否下钻:[^ext4]

Perfetto 官方实景:com.meta.GpuCompute 的汇总视图,把 GPU 工作量按量化指标聚合,用于判断"这段时间 GPU 到底忙了多少"
图 11-17官方实景:com.meta.GpuCompute 的汇总视图——GPU 工作量的量化视图@ Perfetto 官方文档(Google,Apache-2.0)
Perfetto 官方实景:com.meta.GpuCompute 的明细视图,从汇总下钻到单个 kernel 级,与汇总页签对应
图 11-18官方实景:com.meta.GpuCompute 的明细视图——从汇总下钻到 kernel 级@ Perfetto 官方文档(Google,Apache-2.0)

这三张图必须带一句语境提醒:示例是 GPGPU / CUDA 语境。 com.meta.GpuCompute 的定位是计算内核深挖(Details 页签下带 Speed-of-Light、Launch Statistics、Occupancy、Compute Workload Analysis 四个小节),示例里的 kernel 名与 CUDA 相关。图形侧要看到同构视图,前提是 trace 里有 gpu_render_stage 数据——而本机六份 trace 上这张表零命中(sqlite_master 零命中、gpu_slice 恒 0 行);逐条试过 INCLUDE PERFETTO MODULE android.gpu.*(通配可成功,只建出三张 android_gpu_* 表)与 gpu.*(报 Package not found),它不由任何 INCLUDE 创建。它们是"官方 UI 能力上限"的说明,不是"本机采到了这些数据"。

5.7 本章收在哪里

第五章走完,系统层这条腿有了两条硬证据:gpufreq 的峰值与档位分布、GPU Memory 的占用量与进程归属,外加四模块的真实分工与一个静默失效的厂商后缀坑。两条腿都有数了,下一章合起来看。[^ext4]

本章对应的官方依据:GPU 数据源矩阵、厂商后缀与精确名匹配、两套跨层对齐机制见 Perfetto GPU 数据源页;四模块的列与依赖见 stdlib/android/gpu/ 下四个 SQL 文件(目录);android_gpu_frequency 等表条目见 标准库文档索引;六份 trace 的来源见 Perfetto 官方测试数据桶。


六、交叉印证:方向一致,而不是同一帧

下面我们走到第 5 步,本篇最需要说清楚的一步。 两条腿各自都有数了,现在要问:它们能不能合起来给出一个结论? 本章的回答是"能,但只能到某个层级"——能对上结论的方向,对不上同一帧。 这一章会把"为什么"和"够了"都讲清楚。

6.1 两类印证:同帧因果链与方向一致

"印证"这两个字有两种含义,证据强度与成本都差一个量级:同帧因果链能下"因果"结论,方向一致只能下"方向"结论;前者要付同设备同时在采的代价,后者不必。

两类印证对照:左侧是同一帧因果链——同一份数据里 host 侧 API 调用与 GPU 侧工作靠 event id 等值关联,结论可以直接写成"这次调用导致了那段 GPU 工作"
图 11-19两类印证对照:同帧因果链要求同设备同时在采,方向一致只要求结论方向相同——本篇采后者@ 本系列绘制
维度 同帧因果链 方向一致(本篇采用)
要求的数据 一份数据里同时含 host 侧调用与 GPU 侧工作 两份独立证据,各自完整
关联方式 等值关联:event id 显式连两侧 方向比对:两边算出的方向是否相同
能下的结论 "这次调用导致了那段 GPU 工作" "两条独立证据指向同一方向"
证伪能力 强:可被单帧反例推翻 弱:只能被"方向相反"推翻
前置条件 同设备 + 同时在采 + 可对齐的等值键 两份数据各自可信即可

理想形态的机制官方是给了的(1.4 节那两套:event_wait_ids 画 flow 箭头、GpuCorrelation 连接 host 调用与 GPU 工作)[^p1],但示例是 CUDA;图形侧要走到这一步,得先有 gpu_render_stage 数据。

6.2 本机为什么拿不到同帧:三条实测

这一节是本章的诚实标注,三条都是实测事实,不是推测。

  1. 六份真 trace 不是本机产生的。 它们的 GPU 轨道名是 fence-mali.timeline-* 与 fence-CRTC:*-exynos-crtc-* 这类形态——是官方测试数据里 Mali 与 Exynos 平台的 Android 采集,与本机这台 Windows 笔记本无关。
  2. 四份抓帧与那六份 trace 既不同设备也不同进程。 抓帧是桌面 D3D11(RTX 3060 Laptop,驱动 560.70),trace 是移动端 Android,两台设备、两个系统、两个图形后端。
  3. 无法"现抓一份真机帧"配一份同时在采的 trace。 本机 adb devices 在 List of devices attached 之后为空——没有设备接入;官方 Android 抓帧页也写明前置条件:至少 Android 6.0,且包必须已安装且可调试[^r2]。

三条合起来:本机在物理上就不具备"同一帧"的取样条件。 这不是配置能调好的——想让两条腿落在同一帧,先得让它们落在同一台设备、同一段时间里。

6.3 "方向一致"的判据怎么写

既然只能比方向,就必须把"方向"定义得能判。本篇用的判据非常简单,两条线各自给方向,然后比字面是否相同:

判据:方向(API 层可见开销量级 相对 帧预算的位置) == 方向(系统层 GPU 忙闲)

其中:
- 左侧取值:帧内可见开销 / 帧预算。uS 级 → "低";接近或超过 ms 级 → "高"
- 右侧取值:gpufreq 档位(升频 or 降频)+ `utilization.gpu` 活动占比的高低
- 两者同为"低"→ 一致地证明"不是 GPU 瓶颈";同为"高"→ 一致地指向 GPU 侧
- 一高一低 → 不一致,回到 2.1 节的三分支里重判,特别是要检查"数据不足"

最要紧的是"相对帧预算"四个字。 微秒单独看没意义:12.0 µs 在 16.67 ms 的预算里占 0.07%,在 1 ms 的预算里占 1.2%——同一个数,在不同平台指向不同结论。所以左侧必须先除以帧预算,再谈高低。

本机两组数代入后: API 层最大的一份是 25.1 µs / 16.67 ms ≈ 0.15%,方向"低";系统层 utilization.gpu 中位 19.0%、最大 28.0%,方向"低"。两条线一致,结论是"这不是 GPU 瓶颈"——注意措辞:它是"不是 GPU 瓶颈",不是"GPU 没问题",问题在哪要回到判定树往 CPU 分支走。

6.4 三类结论矩阵

下面我们进入第二层:把方向一致之后的结论归类,一共三类。每一类都有"API 层看到什么"与"系统层看到什么"的形态:

三类结论判定矩阵:GPU 忙(饱和)类是 API 层看到大量绘制与高开销、系统层看到频率顶满与占用高
图 11-20三类结论判定矩阵:GPU 忙 / GPU 空转 / GPU 忙但白忙,每类给"API 层看到什么 ↔ 系统层看到什么"@ 本系列绘制
类别 API 层看到什么 系统层看到什么 优化动作的方向
GPU 忙(饱和) 大量绘制、高开销调用、大纹理 gpufreq 顶满、活动占比高 减负载:降分辨率、减绘制、优化着色器
GPU 空转(等 CPU / 帧内没活) 极少绘制(1 到 2 个)、微秒级开销 频率在低档、活动占比低 查提交节奏与资源创建,别动像素
GPU 忙但白忙(被遮挡) 表面读数偏高,但像素被深度测试丢弃 频率与占用都偏高 前置深度预通道、遮挡剔除、重排绘制顺序

第三类是这张表存在的理由。 前两类靠数量级差异就能分辨;第三类两层读数都偏高,一致指向"GPU 在忙",而这恰恰是错的结论——要挑出它,得看"忙的是不是必要的活"。

6.5 第三类的证据:一次遮挡构造性实验

先说限定:下面这组数据来自构造性实验,不是某个真实项目。 做法是让一个遮挡体先画、再画一个被完全遮挡的物体,然后读遮挡查询的通过采样数。

本机遮挡构造性实验:遮挡体 A 的 samples_passed 为 340,472,被遮挡体 B 为 0(完全遮挡)
图 11-21本机遮挡构造性实验:遮挡体 samples_passed 340,472 对被遮挡体 0,深度只有两层、中心区域深度标准差 0.0000@ 本机自跑(perf-auto v0.1.0,RTX 3060 Laptop,RenderDoc v1.29 / Perfetto v58.2)

这组数能证明什么、不能证明什么,必须分开写:

这组数证明了 这组数不能证明
遮挡检测的方法在本机是有效的:完全被遮挡的物体,其 samples_passed 会落到 0,与遮挡体的 340,472 形成清晰落差 不能说"某个真实项目 overdraw 严重"——这是一个静态场景里刻意构造的完全遮挡
深度分布可以被量化:只有两层(0.812 与 0.938),中心 100×100 区域深度标准差 0.0000 不能外推到复杂场景:静态场景下每一帧的读数完全相同,日志里 A=340472 / B=0 连续 481 帧(961 行读数)
"忙但白忙"这一类在数据上是可识别的:表面看 GPU 在画,实际画的全被丢弃 不能当成"优化收益"的预测:真实收益取决于场景的遮挡比例,本实验给不出这个比例

6.6 本章收在哪里

第六章走完,第 5 步该有结论能力了:两类印证的差别、三条实测说明为什么拿不到同帧、判据必须先除以帧预算、三类结论矩阵、第三类证据的构造性实验限定。下一章把四类坑集中钉一遍。[^src9]

本章对应的官方依据:两套跨层对齐机制(event_wait_ids 与 GpuCorrelation)见 Perfetto GPU 数据源页;Android 抓帧的前置条件(Android 6.0 与可调试包)见 RenderDoc Android 抓帧页;帧预算与刷新率的关系见 Android 平台性能度量。


七、边界与陷阱:单位、语境、后缀、退出

下面做第 6 步之前的最后一次体检。 这一章把本篇踩到的坑集中钉一遍。它们的共同特征是"不报错":不报错地给出 0、给出一个单位错误的数、收不到数据、或者不退出。在性能分析里,不报错的错最贵。

7.1 陷阱矩阵:五条不带报错的错

陷阱矩阵五条:gpufreq 的最小值恒为 0 而口径未定论
图 11-22陷阱矩阵:五条不报错的错,逐条附本机实跑标记@ 本系列绘制
# 陷阱 实跑标记 正确姿势
1 gpufreq 的 min 恒为 0 六份 trace 全部为 0 只用峰值与均值;0 的口径未定论,不解释
2 GPU Memory 的单位是字节 峰值 604,762,112 字节 ≈ 577 MiB 写清"字节";不与带宽混读
3 数据源名带厂商后缀,写错静默收不到 官方原文:gpu.counters.adreno / gpu.renderstages.mali,精确名匹配 采集配置里用带后缀名;收不到先查后缀
4 脚本入口跑完不退出 本机实测:30 秒后 qrenderdoc 进程仍在 脚本模板里自带结束进程的收尾
5 文档里有、本机拿不到 gpu_render_stage 与 graphics_frame_event 表零命中、gpu_slice 恒 0 行 先确认表在不在,再写查询

第 5 条最值得展开。 官方把它们当作 UI 插件读的表族来写,但这不等于"你的 trace processor 里一定有"——实测:在本机 v58.2 上,无论是否 INCLUDE PERFETTO MODULE android.gpu.*(通配可成功)或 gpu.*(报 Package not found),这两张表都零命中、gpu_slice 也恒 0 行;它们是随输入数据存废的表,不是靠某个 INCLUDE 补出来的。这个坑只能靠"先问存在性"来兜:写查询之前先用 SELECT count() FROM 确认表在不在,能省半小时的语法自查。

7.2 平台口径矩阵:同一个中文词,两种量

坑的根源多数不在工具,在词——"GPU 使用率""GPU 内存""帧时间限制"这三个词,两侧各自指向不同的量:

平台口径矩阵五列:平台、工具、语义、单位、出处;桌面行的 GPU 使用率来自 nvidia-smi 的 utilization.gpu,语义是活动采样占比、不是 SM 占用率
图 11-23平台口径矩阵:平台 / 工具 / 语义 / 单位 / 出处 五列,把同一个中文词在两侧的不同量并排放@ 本系列绘制
平台 工具 语义 单位 出处
桌面 nvidia-smi 的 utilization.gpu 活动采样占比(0.5 s 轮询),不是 SM 占用率 % 本机 perf-auto 适配器
移动 Perfetto gpufreq 轨道 频率档位:升频↔有活、降频↔空闲 Hz android.gpu.frequency
桌面 帧预算 60 FPS 的一帧可花时间 ms(16.67) 刷新率与帧率
移动 帧时间限制 官方语境的经验阈值(约 40 FPS) ms(25) Android 官方(镜像)
两侧 显存 占用量,不是速率 字节 android.gpu.memory

这张表的作用是"防止跨列对齐"。 看到"桌面 19% 而移动 750,000 Hz"就觉得可以比,是把不同单位、不同语义的量塞进同一个不等式。正确的比法是比"方向",不是比"数值"(回链 6.3 节)。

7.3 官方那条"跨工具联动"的路,只在 AMD + RGP 上

本篇讲的是"两个落点",而官方其实还有一条更硬的路:让两个工具在同一份抓帧上双向跳转。 这条路存在,但有前提:

RenderDoc 官方实景:在 RGP 里右键 Select RenderDoc Event,可以把当前选中的事件同步到 RenderDoc 的事件树
图 11-24官方实景:RGP 与 RenderDoc 的 Select Event 双向联动——API 层事件与 GPU 层事件可以互相跳转@ RenderDoc 官方文档(MIT)
项 官方事实(要点) 意义
开关 默认关闭:Settings → Core → Enable Radeon GPU Profiler integration 联动不是默认开着的
生成 Tools → Create new RGP Profile;菜单没有则需最新 AMD 驱动 桌面侧拿到 GPU 计数器的那条路 = AMD + RGP
可移植 RGP 数据内嵌进 .rdc,可在任何机器上打开,不需要 AMD 驱动或硬件 抓帧文件能"带证据走"
双向跳转 RGP ≥ 1.2 时双向 Select RenderDoc Event / Select RGP Event 官方确有"API 层 ↔ GPU 层"的等值联动
官方自陈 "由于两者对帧的作用域、目的与视图都不同,两个工具的事件编号不会相同" 对不上不是我方臆测,是官方写的

结论很直白: 最硬的那条路(双向 event 跳转)要求 AMD 显卡,本机走不了;而即便是它,官方也自陈"编号不会相同"——连做了双向联动的两个工具都不承诺事件编号一致。[^ext5]

7.4 为什么跨工具对上同一帧这么难

好了,这一节把最容易含糊过去的一句话讲透。 "跨工具对上同一帧"要同时满足三个条件,缺任何一个都只能退到方向级印证:

条件 含义 本机为什么不满足
① 同设备 两份证据必须来自同一块 GPU、同一套驱动与后端 六份 trace 是 Mali / Exynos 的 Android 数据,抓帧是 RTX 3060 的 D3D11
② 同时在采 抓帧与系统采集必须覆盖同一段时间 两者相隔多日,中间隔着一次完全不同的实验
③ 帧号或时间戳可对齐 两侧要有共同的等值键(同一帧号,或可换算的时间戳) 桌面抓帧是"一次一帧",Android trace 是"连续多帧",连帧的概念都不同构

为什么第三条最难? 抓帧工具给的是"被选中的那一帧",它的 EID 是帧内事件序号;系统采集给的是"一段时间里的所有帧",键是帧标识或时间戳。两者没有天然交集:前者是"帧内下标",后者是"全局时间"——官方那句"作用域、目的与视图都不同"说的正是这件事,这不是 bug,是两个工具对"帧"的定义本就不同。

更本质的一层原因:跨工具的"同一帧"需要同一条软件栈的协同。 RGP 与 RenderDoc 能双向跳转,是因为两者都被设计成能读写同一份 .rdc 里的嵌入数据,且限定在 DirectX 12 与 Vulkan 可插桩范围内;Perfetto 与 RenderDoc 之间没有这层协同,唯一的官方连接点是 1.4 节那两套机制,而它们在图形侧需要 gpu_render_stage 数据才成立。

所以"方向一致"不是妥协,是当前条件下唯一诚实的层级。 要让两条腿真正对上同一帧,必须同时具备 同一设备 + 同时在采 + 一个共同的等值键——缺一件就回到这条路。这也是留给 ep13《如何验证的闭环手册》的输入。

7.5 本章收在哪里

第七章走完,五条不报错的陷阱、五列平台口径矩阵、跨工具联动的真实前提(AMD + RGP,只覆盖 DX12/Vulkan)、对上同一帧的三个必要条件都钉住了。下一章收口。

本章对应的官方依据:RGP 联动开关、Select RenderDoc Event 双向跳转与"事件编号不会相同"的自陈见 RenderDoc RGP 联动页;RGP 与 RenderDoc 的互操作范围(仅 DX12 与 Vulkan)见 RGP 手册的互操作章节;厂商后缀与精确名匹配见 Perfetto GPU 数据源页。


八、收口清单与结语

好了,最后一章做三件事: 把全篇压成一张可截图的清单,把边界再钉一遍,把下一站指清楚。

8.1 收口清单:症状 / API 层 / 系统层 / 印证点 / 边界

先来看看这张"随身卡":左边是症状,中间两列是两条腿该看的数,第四列是印证口径,第五列是适用边界。

收口五列清单:症状列列出帧时间长、GPU 未见吃满、GPU 忙但画面没变好三类
图 11-25收口五列清单:症状 / API 层 / 系统层 / 印证点 / 边界@ 本系列绘制
症状 API 层看什么 系统层看什么 印证点 边界
帧时间长、GPU 忙 绘制次数、大纹理、高开销调用 gpufreq 是否顶满 同"高"→ 指向 GPU 侧 25 ms 属移动语境
帧时间长、GPU 没吃满 绘制是否少到 1 到 2 个 频率是否在低档 同"低"→ 查 CPU 侧 utilization.gpu 非 SM 占用率
GPU 忙但画面没变好 被遮挡像素的计数器落差 频率与占用都偏高 需第三类判据 构造性实验只证明方法有效
拿不到 GPU 计数 枚举数与取值数是否一致 表是否存在、是否 0 行 数据不足不是结论 本机 plugins/ 无 nv

再压一层,本篇只有四个动作:先判方向再深挖(顺序不能反)、左侧除预算(微秒要除以帧预算才有意义)、0 行与 0 值分开写(没采到不等于采到了 0)、同帧对不上就只比方向(不假装等值)。四条都做到,这条链路就不会给出反向结论。

8.2 边界再确认表

0.5 节开篇给过一次边界,走完全篇之后再确认一次:

本篇讲了 本篇没讲(→ 属哪篇)
六步链路与两个落点的分工 抓帧注入、回放、事件树与资源视图读法 → ep01 到 ep06
API 层三件套取数与 Top API 归因 trace 文件的 protobuf 结构 → ep07
计数器枚举成功但取值为 0 的成因链 data source 注册与路由 → ep08
gpufreq 与 GPU Memory 的取法与单位 PerfettoSQL 的物化、索引与区间连接调优 → ep09
"方向一致"的判据与三类结论矩阵 FrameTimeline 的帧判据与 jank 归因 → ep10
跨工具同帧对齐的三个必要条件 把三件条件凑齐的闭环手册 → ep13

最后一行是本篇唯一的"向前开口":把"对不上同帧"拆成三个可检查的条件之后,凑齐并写成流程,是 ep13 的题域。

8.3 资源清单与参考资料来源

  • RenderDoc 性能计数器面板 —— https://renderdoc.org/docs/window/performance_counter_viewer.html
  • RenderDoc 与 RGP 联动 —— https://renderdoc.org/docs/how/how_rgp_profile.html
  • RenderDoc Android 抓帧 —— https://renderdoc.org/docs/how/how_android_capture.html
  • RenderDoc Python API —— https://renderdoc.org/docs/python_api/index.html
  • RenderDoc 命令行参考 —— https://renderdoc.org/docs/cmdline.html
  • RenderDoc Releases(本机 v1.29 与官方 v1.46 的版本差)—— https://github.com/baldurk/renderdoc/releases
  • Perfetto GPU 数据源页 —— https://perfetto.dev/docs/data-sources/gpu
  • Perfetto 标准库 android/gpu/ 源码目录 —— https://github.com/google/perfetto/tree/main/src/trace_processor/perfetto_sql/stdlib/android/gpu
  • Perfetto 标准库文档索引 —— https://perfetto.dev/docs/analysis/stdlib-docs
  • Perfetto SQL 表参考 —— https://perfetto.dev/docs/analysis/sql-tables
  • Trace Processor 命令行参考 —— https://perfetto.dev/docs/reference/trace-processor-cli
  • Trace Processor 会话与嵌入 —— https://perfetto.dev/docs/analysis/trace-processor
  • PerfettoSQL 入门 —— https://perfetto.dev/docs/analysis/perfetto-sql-getting-started
  • Perfetto UI 使用说明 —— https://perfetto.dev/docs/visualization/perfetto-ui
  • Android trace 分析入门 —— https://perfetto.dev/docs/getting-started/android-trace-analysis
  • 时钟同步概念 —— https://perfetto.dev/docs/concepts/clock-sync
  • Trace Processor 架构设计 —— https://perfetto.dev/docs/design-docs/trace-processor-architecture
  • Perfetto 官方测试数据桶(六份 trace 来源)—— https://storage.googleapis.com/perfetto/test_data/
  • Trace Processor 下载入口 —— https://get.perfetto.dev/trace_processor
  • Android 官方《分析和优化游戏性能》(镜像站)—— https://developer.android.google.cn/games/optimize/gameperformance
  • AGI 系统性能分析概览(镜像站)—— https://developer.android.google.cn/agi/sys-trace/system-profiler
  • Android 平台性能度量(镜像站)—— https://developer.android.google.cn/topic/performance/measuring-performance
  • NVIDIA Peak-Performance-Percentage(厂商博客,旁证)—— https://developer.nvidia.com/blog/the-peak-performance-analysis-method-for-optimizing-any-gpu-workload/
  • AMD RGP 与 RenderDoc 互操作(厂商手册,旁证)—— https://gpuopen.com/manuals/rgp_manual/renderdoc_and_rgp_interop/

参考资料来源:API 层真值全部来自本机自跑(perf-auto v0.1.0 的 F1 与 occlusion-demo 场景,桌面 D3D11,RenderDoc v1.29);系统层真值来自本机对六份官方 Android 测试 trace 的实跑(trace_processor_shell v58.2-add693d8b);机制与阈值取自两个官方站一手文档;厂商博客与镜像站材料仅作旁证。"本机拿不到 NVIDIA 硬件计数器"在第四、六两章各写明一次,未用近似值顶替。

8.4 下篇预告与落款

下篇预告:ep12《实战二:掉帧,到底是 jank 还是 shader》——那一帧确实掉了,掉在 jank 一侧还是 shader 一侧? 前者用 FrameTimeline 的判据定位帧与责任方,后者靠反汇编看那条着色器在算什么——这两条路各自往下的第一跳,就是 ep12 的全部内容。

以上。 希望这一路"从感觉卡到指出哪一层"的梳理,能让你下次听到"这游戏 GPU 不行"时不再直接换卡,而是一路问下去:这个数什么口径、它相对帧预算是多少、另一层的方向对不对得上。

本文由@溯光 出品,让"GPU 到底忙不忙"变成一个可复现的顺序判断。

With Best Wishes.


[^ext1]: 取自 Perfetto 官方文档 docs/images/gpu-tracks.png(配套 https://perfetto.dev/docs/data-sources/gpu),Google,Apache-2.0;实测 2402×1318。

[^ext2]: 取自 RenderDoc 官方文档 _images/counter_view.png,Baldur Karlsson,MIT;实测 777×657,按原理小图落地。

[^ext3]: 取自 RenderDoc 官方文档 _images/counter_selection.png,MIT;实测 648×507。

[^ext4]: 三张取自 Perfetto 官方文档 docs/images/:gpu-by-process.png(2206×314)、gpu-compute-summary.png(2402×1202)、gpu-compute-details.png(2400×1354),Google,Apache-2.0;示例为 GPGPU/CUDA 语境。

[^ext5]: 取自 RenderDoc 官方文档 _images/SelectRGPEvent.png,MIT;实测 541×320(同页 SelectRenderDocEvent.png 为 701×465,已被 ep04 使用,本篇不复用)。

[^src1]: 自跑:renderdoccmd convert 产物统计——B/E 配对 47/38/33/49、draw 与 dispatch 1/2/1/2、帧内 API 总耗时 12.0/14.4/10.1/25.1 µs。

[^src2]: 自跑:Top 三项 CreateTexture2D / CreateBuffer / CreateQuery;另一份上 CreateQuery ×2 合计 9.0 µs。

[^src3]: 自跑:renderdoccmd thumb 导出,1264×681(长边差 16 px,按半栏图落地)。

[^src4]: 自跑:界面四区按 Qt 布局探测矩形并逐像素校验后标注,1622×956。

[^src5]: 自跑:EnumerateCounters() 14 条(13 个通用计数器 + 1 条 ERROR: Could not find Nsight Perf SDK library 占位项);FetchCounters 逐 event 返回,7 个具名计数器 × 5 个根动作 = 35 行;event 22 上 Samples Passed / PS Invocations = 340,472;views.json 的 counters 段 7 项全 0.0 系旧脚本按名装 dict 后被最后一个 event(EID 32,Present)覆盖所致;同帧根动作 5 个、绘制 2 个、纹理 3 张。

[^src6]: 自跑:gpufreq 峰值 750,000 / 434,000 / 302,000 / 257,000 / 434,000 Hz,webview_jank.pb 0 行,min 六份恒 0。

[^src7]: 自跑:GPU Memory 峰值 604,762,112 / 253,927,424 / 462,737,408 字节,轨道 16/6/12,落到进程 6,200/1,213/416 行。

[^src8]: 自跑:nvidia-smi 0.5 s 轮询 27 样本——gpu_util_pct 中位 19.0% / 最大 28.0% / p90 23.0,power_w 19.87,temp_c 71.0。

[^src9]: 自跑(构造性实验):遮挡体 A samples_passed 340,472、被遮挡体 B 0(该读数并可与抓帧侧 FetchCounters 在遮挡体 draw(EID 22)上的 Samples Passed 340,472 相互印证),深度两层 0.812→340,472、0.938→520,312,中心区域 std 0.0000,日志连续 481 帧(961 行读数)逐帧相同。

[^r1]: RenderDoc 原文:该面板"取回整份抓帧中所有事件上的 GPU 计数器结果",流程为 Capture Counters → 选择 → Sample Counters → CSV。

[^r2]: RenderDoc 原文:支持 Vulkan 与 OpenGL ES,设备至少 Android 6.0,且包必须已安装、必须可调试。

[^n1]: NVIDIA 原文列出 DX11 需关注调用:Flush{,1}、Map、UpdateSubresource{,1}、任何 Create/Release;本篇只引这一条。

[^p1]: Perfetto 原文:event_wait_ids 用于在相关 GPU 切片间画 flow 箭头;GpuCorrelation 的 render_stage_submission_event_ids / render_stage_wait_event_ids 用于连接 host 调用与 GPU 工作(示例为 CUDA)。

[^p2]: Perfetto 官方示例:counter_leading_intervals! 展开区间后按 SUM(u.value × ii.dur) / k.dur 求时间加权平均利用率,示例 180000 ns / 78.27。

[^p3]: Perfetto 原文:数据源常用带硬件后缀的注册名(如 gpu.counters.adreno),跟踪服务使用精确名匹配——写错后缀不会报错,只会静默收不到。

[^a1]: Android 官方(镜像站):第 2 步 CPU/GPU 绑定检查(Perfetto)、第 4 步 GPU 优化(RenderDoc);判据含 25 毫秒、>90%、>15% 三条。

[^a2]: Android 官方 AGI 页(镜像站):系统分析器基于 Perfetto 构建;GPU 计数器"特定于硬件",轮询速率因计数器类型而异。