GPU 架构剖析 · 第 一 篇

一次 Draw 走了多少路:从 API 到命令前端

2026-09-1514,958 字126 张配图本文由@溯光 出品,转载请注明出处。

系列配套:GitHub 源码仓库(demo 代码与生成本次全部示意图的工具)|在线阅读入口(本站发布) 题图来自《A trip through the Graphics Pipeline 2011》(Fabian Giesen,CC0 1.0)。

一句话点题

0.1 一次 Draw 提交之后

在 API 的世界里,「画一次」只是一行调用:D3D 的 DrawIndexed、Vulkan 的 vkCmdDrawIndexed、GL 的 glDrawElements。但从这行调用返回,到 GPU 真正开始处理,中间隔着一整条我们平时看不见的路——一次绘制在这条路上先是变成一串命令包,再被写进一块环形缓冲,最后被一颗叫命令处理器(Command Processor)的硬件单元逐个捞出来、译成寄存器的写。

命题图:一行 API 调用到一串命令包的粗粒度映射
题图命题图:一行 API 调用到一串命令包的粗粒度映射@ Wikimedia Commons · Martijn Boer(Public domain)

这一篇要回答的三个问题其实就一件事:谁在提交、提交成什么、前端怎么消化。我们把「一行调用的返回」当成起点——因为这是每一位图形程序员都能亲手摸到的第一个可观测点;把「命令处理器取出下一条命令包」当成终点——因为从那里开始,数据就进了 GPU 自己掌握的节奏(In-Flight 状态),CPU 想再插话就难了。

一条 draw 从 API 到前端入口的完整路径:五层软件栈 + 环形缓冲 + 命令处理器
图 1-1一条 draw 从 API 到前端入口的完整路径:五层软件栈 + 环形缓冲 + 命令处理器@ Wikimedia Commons · Mmanss(CC BY-SA 3.0) · https://creativecommons.org/licenses/by-sa/3.0

我们要给「提交一次绘制」画一张可核对的地图:谁在 CPU 侧把命令组织好,谁在 GPU 侧把它吃下去,中间隔着哪些缓冲与寄存器,每一步是一次函数调用还是一条总线上的搬运。

三条量级对照:函数调用 / 写缓冲 / 寄存器读写的代价梯度
图 1-2三条量级对照:函数调用 / 写缓冲 / 寄存器读写的代价梯度@ 本系列绘制

0.2 本篇在系列里的位置与边界

本系列《GPU 架构剖析》共十二篇,讲的是同一件事的另一层:同一个渲染管线,在硅片上是由哪些单元、按什么顺序、付什么代价完成的。前两个系列分别在算法层(《实时渲染管线探析》)和约束层(《移动端图形与功耗》)讲过它,本篇从硬件实现层切入,是第一站。

整个系列是一条从命令走向像素的线:命令前端(本篇)→ 显存与地址空间 → 着色器核 → 纹理与缓存 → 光栅化与像素后端 → 架构演进与逐年回归,本篇正站在这条线的起点。

这篇的边界要先说死,免得牵扯不清:本篇只讲「命令怎么进去」,不碰「进去之后显存怎么排布」——那是 ep02《显存与地址空间》的主场;也不重讲移动端 TBR/TBDR 的取舍,那在《移动端图形与功耗》ep02/ep03 已经写透,本篇只在必要处引用结论。

边界声明图:本篇只管提交路径,显存细节与移动端带宽账划归后续篇目
图 1-3边界声明图:本篇只管提交路径,显存细节与移动端带宽账划归后续篇目@ 本系列绘制

一句话把本篇点透:理解一次 Draw 的提交,就等于理解「CPU 与 GPU 之间那层薄薄的、却决定了无数性能问题的协议」。

一、从 API 到硅片:五层软件栈

1.1 原理与概念:五层栈的分工

我们习惯说「一次 draw 到 GPU」,但实际上从你的程序到 GPU 的寄存器,中间隔着至少五层。这五层不是同一家公司写的,它们各管一段,彼此通过约定好的接口交接。

五层软件栈:应用 → API 运行时 → 用户态驱动 → 调度器/内核驱动 → 硬件
图 1-4五层软件栈:应用 → API 运行时 → 用户态驱动 → 调度器/内核驱动 → 硬件@ 本系列绘制

第一层是你的应用——它决定「画什么、用什么状态画」,把绘制意愿表达成 API 调用。第二层是 API 运行时——它把调用转成更底层的对象操作,维护句柄(Handle)与资源表。第三层是用户态驱动(User-mode Driver, UMD)——它是关键中的关键,负责把 API 层的高级操作翻译成 GPU 能懂的机器指令级序列,也就是命令缓冲。第四层是内核态驱动与其调度器。第五层才是硅片本身。

数据表:五层各自的职责、产出的数据形态、与一次 draw 的关系
图 1-5数据表:五层各自的职责、产出的数据形态、与一次 draw 的关系@ 本系列绘制

值得停下来强调的是第三层:着色器其实是用户态驱动编译的,不是 GPU「自己」会的。你在闪烁屏幕上敲进去的 HLSL/GLSL,在创建 PSO(Pipeline State Object,管线状态对象)时被用户态驱动编译成设备相关的指令,再被封装成命令包。所以「驱动程序」这个称呼容易误导——它不只是做调度,它是那个「让高级语言跑在硅片上」的编译器与翻译官。

着色器文件从源码到设备指令的编译链:源码 → 字节码 → 中间表示 → 目标指令
图 1-6着色器文件从源码到设备指令的编译链:源码 → 字节码 → 中间表示 → 目标指令@ 本系列绘制

拿开源栈能看得更清楚。Linux 上最常见的组合是 Mesa(用户态)→ DRM(Direct Rendering Manager,内核态)→ AMDGPU/radeon(内核驱动),每一层的边界被内核源码和接口文档钉得死死的,不存在「谁都能改任意层」的情况。真实的图形软件栈分层,比任何抽象图都更能说明「五层」不是一句比喻。

真实 Linux 图形栈分层:Mesa、DRM、AMDGPU 的实际边界
图 1-7真实 Linux 图形栈分层:Mesa、DRM、AMDGPU 的实际边界@ Wikimedia Commons · ScotXW(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0
GPU 加速的原理:CPU 与 GPU 的职责划分与数据搬运路径
图 1-8GPU 加速的原理:CPU 与 GPU 的职责划分与数据搬运路径@ Wikimedia Commons · Mmanss(CC BY-SA 3.0) · https://creativecommons.org/licenses/by-sa/3.0

1.2 用户态驱动在做什么

用户态驱动日常其实只做四件事:编着色器、分配资源、创建视图、写命令缓冲。前两件偏向「慢而少」的管理工作,后两件偏向「快而多」的每帧工作。把分工画出来,你就能理解为什么性能问题一半出在「不该每帧做的事被拖进了每帧循环」。

用户态驱动的四件事职责图,标注「每帧固定」与「创建时一次」的分界
图 1-9用户态驱动的四件事职责图,标注「每帧固定」与「创建时一次」的分界@ 本系列绘制

编译着色器往往不在 draw 调用里发生,而是发生在创建 PSO 时——这也是为什么首次创建 PSO 会卡一下,而 Warm-up(预热)阶段常看到「Dummy Draw」:先把会用的管线、资源、图元都过一遍,把迟到的编译和分配提前到加载期做完。

时序图:资源分配为何延迟到首次使用(dummy draw 预热)
图 1-10时序图:资源分配为何延迟到首次使用(dummy draw 预热)@ 本系列绘制

资源分配还有个不直观的事实:同一个着色器,会因为状态组合的不同存在多份变体(Variant)。同一个 VS 在「深度写开/关」「遇混合开/关」时,可能分别被编译成不同的指令序列。这就是所谓「变体爆炸」的源头,也是 PSO 数量失控时 CPU 侧卡顿的根源。

同一个着色器因状态组合派生多份变体的示意
图 1-11同一个着色器因状态组合派生多份变体的示意@ 本系列绘制

现代显式 API(Vulkan、D3D12)的用意到这里就亮出来了:它把资源如何组织、状态如何切换的选择权从驱动手里交还给应用。Vulkan 的简化管线从功能上看「少」,但少的是驱动偷偷替你做的优化——你拿回的是确定性。

隐式 API(驱动代劳)与显式 API(调用者自定)在管线组织上的对照
图 1-12隐式 API(驱动代劳)与显式 API(调用者自定)在管线组织上的对照@ Wikimedia Commons · Alexander Overvoorde(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0

1.3 内核态驱动与前端的唯一性

与用户态驱动可以开很多份、每进程一份不同,内核态驱动在系统里通常只有一份实例。这不是巧合:它掌管的是物理硬件、是只能被一个实体发号施令的资源。

为什么内核态驱动只有一份:独占物理设备、共享中断、串行提交
图 1-13为什么内核态驱动只有一份:独占物理设备、共享中断、串行提交@ 本系列绘制

内核态驱动的职责清单很专一:管理物理显存、设置显示模式、处理 GPU 中断、跑设备看门狗(Watchdog,防止 GPU 卡死拖垮系统)。它不做「编译着色器」这种每进程的活,因为那是用户态的事。设备驱动在整个系统结构里的位置,从操作系统教科书到 Linux 内核文档都能看到同样的骨架。

设备驱动在系统结构中的位置:应用 / 库 / 内核 / 设备的分层
图 1-14设备驱动在系统结构中的位置:应用 / 库 / 内核 / 设备的分层@ Wikimedia Commons · The original uploader was Bruce Shorty at Czech Wikipedia.(Public domain)
内核态驱动的职责清单:物理内存 / 显示模式 / 中断 / 看门狗
图 1-15内核态驱动的职责清单:物理内存 / 显示模式 / 中断 / 看门狗@ 本系列绘制

用户态和内核态之间的每次跨跃,代价都不小——一次系统调用意味着权限切换、栈切换、安全检查,动辄几百甚至上千周期。所以图形栈的设计原则是把高频操作放在用户态(写命令缓冲就在用户态完成),只在必要时才穿过内核。

用户态与内核态一次往返的代价:系统调用 vs. 纯用户态写缓冲
图 1-16用户态与内核态一次往返的代价:系统调用 vs. 纯用户态写缓冲@ 本系列绘制

1.4 实现与配置:一次 DrawIndexed 的参数与状态

把镜头推到最具体的一次调用 DrawIndexed。它的参数不多,但每一个都指向下游某个具体的硬件单元。

DrawIndexed 的索引数、起始索引、基数顶点等参数各自流向哪个硬件单元
图 1-17DrawIndexed 的索引数、起始索引、基数顶点等参数各自流向哪个硬件单元@ 本系列绘制

绘制之前,GPU 侧必须「就绪」的状态集合其实远超相机与模型矩阵。深度状态、光栅状态、混合状态、VS/PS、顶点输入布局、目标格式……这一大坨东西在显式 API 里被封装成一个 PSO,一次绑定,省去运行时逐项校验。

绘制前必须就绪的状态集合清单
图 1-18绘制前必须就绪的状态集合清单@ 本系列绘制

想看清这些阶段各吃进、输出什么,最好把它们拿到同一张逻辑管线图里——从 IA(Input Assembler,输入装配)一直排到 OM(Output Merger,输出合并)。

逻辑管线各阶段的输入输出:IA/VS/GS/SO/RS/PS/OM 的统一坐标系
图 1-19逻辑管线各阶段的输入输出:IA/VS/GS/SO/RS/PS/OM 的统一坐标系@ Wikimedia Commons · Yanddalf(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0

参数校验发生在哪一侧,也值得说清:显式 API 把大部分校验放到创建期——PSO 创建时就把组合合法性查完;而 draw-time 只剩「符号与描述符对不对得上」这类轻校验。这正是 D3D12 的 CPU 效率设计动机之一。

参数校验在创建期(重)与运行期(轻)的分工对照
图 1-20参数校验在创建期(重)与运行期(轻)的分工对照@ 本系列绘制

1.5 效果与量化:五层的调用次数与耗时量级

把五层摆在同一张表上,量级差异一眼就能看出来:调用次数从「每帧几次」到「每 draw 几百次」隔着三个数量级,耗时从纳秒到毫秒也隔着三个数量级。

五层各自的调用次数与耗时量级对照表
图 1-21五层各自的调用次数与耗时量级对照表@ 本系列绘制

其中最容易踩坑的一点是「哪一层是每帧一次」。资源创建、管线创建这类管理调用是「每帧一次都嫌多」的;而写命令缓冲、绑定描述符这类是「每个 draw 都得来」的。把高频的东西藏进低频的调用里,是常见的性能反模式。

「每帧一次」vs「每个 draw 一次」的频率分档
图 1-22「每帧一次」vs「每个 draw 一次」的频率分档@ 本系列绘制

提交路径上有几个可观测点:API 调用返回前的 C++ 栈、CPU 侧命令缓冲末尾、GPU 侧前端计数器。三个点分别回答「用户态花了多少」「驱动写了多少」「硬件吃了多少」,对不上号说明某一层出了问题。

一次提交路径上的三个可观测点及各自语义
图 1-23一次提交路径上的三个可观测点及各自语义@ 本系列绘制

纳秒、微秒、毫秒三个数量级放在一条尺子上,读者就能直观看到:一次 draw 的成本即使被拆到最细,也不会掉到一个「感觉无所谓」的数量级——提交本身就有固定开销,这为后面第五章埋了伏笔。

纳秒 / 微秒 / 毫秒三档直观对照尺
图 1-24纳秒 / 微秒 / 毫秒三档直观对照尺@ 本系列绘制

二、主命令缓冲:为什么它是环形缓冲

2.1 原理与概念:环形缓冲的形态

进入 GPU 侧之前,先把 CPU 侧最后一块拼图讲清——命令缓冲。它分为两种:主命令缓冲(Primary Command Buffer)与次命令缓冲(Secondary Command Buffer,即命令列表)。主缓冲里放的,是系统级命令和对 3D 命令缓冲的调用;3D 命令缓冲才是装「画什么」的地方。

主命令缓冲与 3D 命令缓冲的关系:主缓冲只放系统命令与一条条调用
图 1-25主命令缓冲与 3D 命令缓冲的关系:主缓冲只放系统命令与一条条调用@ 本系列绘制

为什么要用环形缓冲,而不是推到底就结束的线性缓冲?核心原因是读写解耦:CPU 是生产者,从一端把命令包写进去;GPU 是消费者,从另一端把命令包读出去。环形结构让写指针和读指针各走各的,生产者永远在前、消费者永远在后,谁也不堵谁——只要缓冲区没被耗尽。

环形缓冲的基本形态:扁平内存按环使用,头尾相接
图 1-26环形缓冲的基本形态:扁平内存按环使用,头尾相接@ Wikimedia Commons · This file was made by User:Sven Translation If this image contains text, it can be translated easily into your language. If you need help, contact me Flexible licenses If you want to use this picture with another license than stated below, contact me Contact the author If you need a really fast answer, mail me. If you need only a fast answer, write me here.(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0

环形缓冲有两个指针:读指针(Read Pointer)与写指针(Write Pointer)。读者追上写者说明读得比写得快——缓冲区空了;写者追上读者说明写得比读得快——缓冲区满了。

环形缓冲的读写指针与回绕:两个指针追赶、回绕到底的机制
图 1-27环形缓冲的读写指针与回绕:两个指针追赶、回绕到底的机制@ Wikimedia Commons · en:User:Cburnett(CC BY-SA 3.0) · http://creativecommons.org/licenses/by-sa/3.0/

环形 vs 线性不是口味问题:线性缓冲被读到底就必须「倒带」或新建,生产者必须等消费者清空;环形缓冲允许生产者绕回,把「当前批次 vs 历史批次」自然区分开,是显式且自洽的。

环形 vs 线性缓冲:读写解耦 vs 倒带等待的对照
图 1-28环形 vs 线性缓冲:读写解耦 vs 倒带等待的对照@ 本系列绘制

缓冲耗尽时的行为很值得记一笔:写指针逼近读指针时,驱动要么阻塞等待 GPU 消费(这会让 CPU 停下来,是最典型的「驱动卡顿」来源),要么吐掉一部分内容改走更慢的路径。这两种都是你要在 profiler 里找的病征。

缓冲耗尽时的两种处置:阻塞等待 vs 回退慢路径
图 1-29缓冲耗尽时的两种处置:阻塞等待 vs 回退慢路径@ 本系列绘制

2.2 命令包的格式与前端解码状态机

GPU 前端拿到的不是「一次 draw」,而是一条条命令包(Command Packet)。每个包有一个最小骨架:类型 + 长度 + 载荷。类型告诉解码器「这是什么」,长度告诉解码器「这个包占多少字节」,载荷装着参数。

命令包的基本结构:类型 / 长度 / 载荷三段
图 1-30命令包的基本结构:类型 / 长度 / 载荷三段@ 本系列绘制

真正在解码这些包的,是一台叫命令处理器的专用状态机。它不是通用 CPU,它的指令集非常窄——只认识一串紧凑的命令包格式,解码速度快、面积小。可以说,前端就是一台解码状态机。

一张来自开放社区的 AMD GCN 命令处理流程图,直接画出了这个阶段:命令从环形缓冲被拾起,被译成寄存器的写,再被分发到底下的各执行单元。这是本篇所有外部示意图里与主题最对位的一张。

命令处理器解码并分发命令的真实流程:取包 → 解码 → 分发(GCN 图)
图 1-31命令处理器解码并分发命令的真实流程:取包 → 解码 → 分发(GCN 图)@ Wikimedia Commons · ScotXW(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0

如果把这台命令处理器再拆开看,它其实是一个由好几块分工不同的子部件组成的整体:前端有个命令缓冲预取器(Command Buffer Prefetcher)提前把还没生效的命令拉进一条很深的 FIFO;FIFO 之后是解码与分发(Decode + Dispatch)单元,把包解开、按类型分流;进入内部后,执行(Execute)单元负责真正的行为,同步/等待(Sync/Wait)单元处理「若事件 X 则 Y」的等待,3D/计算分发把命令送到各自的管线前端。CPU 一侧通过公共寄存器(Public Registers)看到并操纵这些内部状态——这正是上一节说「读写指针是寄存器」的由来。这一整块,就是 GPU 的那颗「命令状态机」。

命令处理器的内部组成:预取器 + 长 FIFO + 解码分发 + 执行 + 同步 + 3D/计算分发
图 1-32命令处理器的内部组成:预取器 + 长 FIFO + 解码分发 + 执行 + 同步 + 3D/计算分发@ Fabian Giesen《A trip through the Graphics Pipeline 2011》Part 2(作者 Public domain) · https://farbrausch.de/~fg/gpu/command_processor.jpg

命令包的格式设计,与 CPU 指令编码的「定长 vs 变长」是一个同构问题。固定长度好解码、好对齐,但浪费带宽;变长省带宽、但解码要现场判断边界,前端更复杂。显卡命令包通常在两者间找平衡:常用命令用紧凑的变长格式,批量状态用定长格式。

定长与变长指令编码的取舍:解码复杂度与带宽的权衡
图 1-33定长与变长指令编码的取舍:解码复杂度与带宽的权衡@ Wikimedia Commons · Oğuz Ergin(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0

虽然时代在变,2D 命令至今仍常留一块专用硬件。不是现代 GPU 不会画 2D,而是桌面合成、文本渲染这类 2D 工作负载吃的是「低延迟、确定性」——把它从通用 3D 前端里分出去,避免 3D 的流水状态拖累它。

3D 前端与 2D 专用硬件的分工,为什么 2D 保留专用单元
图 1-343D 前端与 2D 专用硬件的分工,为什么 2D 保留专用单元@ 本系列绘制

2.3 读写指针是内存映射寄存器

接下来是这个机制里最反直觉、也最重要的一环:读写指针并不放在显存或内存里,而放在命令处理器上的内存映射寄存器(Memory-Mapped Register, MMR)里。

读写指针不在内存,而在命令处理器侧的内存映射寄存器上
图 1-35读写指针不在内存,而在命令处理器侧的内存映射寄存器上@ 本系列绘制

普通内存访问要过缓存、走总线,Core 与 CCU 之间的读写是带状态的;而内存映射寄存器是 CPU 与 GPU 共享的浅层、低延迟通道——CPU 一侧写,GPU 一侧直接读,中间不经过那个高延迟的主存储层次。这两类的差别,决定了「同步命令该走哪条路」。

内存映射寄存器 vs 普通内存访问的差别:通路、延迟、缓存行为
图 1-36内存映射寄存器 vs 普通内存访问的差别:通路、延迟、缓存行为@ 本系列绘制

一次提交的完整时序可以画成一条链:CPU 层序地把命令包写进环形缓冲 → 推进写指针 → 触发一次「敲门铃」(Doorbell,写一个特殊寄存器)→ 命令前端被唤醒 → 取指开始读命令包。

提交时序:写缓冲 → 推指针 → 敲铃 → 前端取指的完整链
图 1-37提交时序:写缓冲 → 推指针 → 敲铃 → 前端取指的完整链@ 本系列绘制

寄存器/内存映射的「文件式」表示,在硬件手册里通常就是一张 SFR(Special Function Register,特殊功能寄存器)内存映射表——每个偏移、每一位、什么含义、谁可以写。

SFR 内存映射表的文件化表示:偏移与位的语义
图 1-38SFR 内存映射表的文件化表示:偏移与位的语义@ Wikimedia Commons · PD-scan(Public domain)

2.4 实现与配置:命令包布局示例

命令包的布局可以用一级伪码 + 逐字段释义来落到纸上。一个典型的「设置状态」包可能是:

DWORD 0 : OPCODE = 0x0A, CNT   = 5          ; 类型与包内字数
DWORD 1 : REG_BASE = 0x0A1C                  ; 要写的寄存器基址
DWORD 2 : DATA[0] = depth_enable             ; 按序写入的寄存器值
...
命令包布局伪码 + 逐字段释义:OPCODE/CNT/载荷
图 1-39命令包布局伪码 + 逐字段释义:OPCODE/CNT/载荷@ 本系列绘制
命令数据缓冲的配置:环形缓冲内各命令数据区的排布
图 1-40命令数据缓冲的配置:环形缓冲内各命令数据区的排布@ Wikimedia Commons(Public domain) · https://commons.wikimedia.org/wiki/File:Command_Data_Buffer_configuration.png

而一次 DrawIndexed 展开来看,不是一条包,而是一串:先是定义状态(深度、光栅、混合),再是绑定管线,最后才是那条「开始画 N 个索引」的绘制包。

一次 draw 展开成哪些命令包:状态包序列 → 绑定包 → 绘制包
图 1-41一次 draw 展开成哪些命令包:状态包序列 → 绑定包 → 绘制包@ 本系列绘制

状态包与绘制包之间存在先后顺序约束——绘制包依赖它之前的状态包已经把相应寄存器写到位。这解释了为什么「乱序提交状态」会触发不必要的重解析或冲刷。

状态包与绘制包的先后顺序约束,违反时发生什么
图 1-42状态包与绘制包的先后顺序约束,违反时发生什么@ 本系列绘制

把命令包与 API 调用对照起来,你会看到多对多而非一对一的映射:一次 vkCmdBindPipeline 可能拆成好几条寄存器写,一条绘制包也可能对应接口上的一次绑定加一次绘制。这层映射是驱动优化的主战场之一。

命令包与 API 调用间多对多的映射关系
图 1-43命令包与 API 调用间多对多的映射关系@ 本系列绘制

2.5 效果与量化:前端吞吐与预取

GPU 在执行一个长命令流时,深度预取(Prefetch)是抵消高延迟的主要手段:命令前端不会等把整个缓冲拉进本地才开始干活,而是把「还没轮到」的命令包提前拉进片上的浅队列,让主存储的高延迟被隐藏在大规模流水里。

深度预取机制:前端提前拉取未生效命令,隐藏主存储延迟
图 1-44深度预取机制:前端提前拉取未生效命令,隐藏主存储延迟@ 本系列绘制
前端带宽与命令包体积的关系:包越大,同样带宽下能预取的命令越少
图 1-45前端带宽与命令包体积的关系:包越大,同样带宽下能预取的命令越少@ 本系列绘制

命令包体积是能实测的口径:同一次绘制,不同硬件、不同版本驱动下的包长可能差几倍。这也是为什么有时「增加 draw 数」比「增加顶点数」更伤——前者线性涨的是前端带宽与解码量。

命令包体积的实测口径:同负载跨硬件/驱动朝代差
图 1-46命令包体积的实测口径:同负载跨硬件/驱动朝代差@ 本系列绘制

当片上前端 FIFO 深度不足时,前端会「吃」得比预取快:命令供不应求,执行单元被迫干等,GPU 进入欠载(Underutilization)——这就是 draw call 上限总是撞在「前端带宽/解码」而不是光栅化上的原因。

FIFO 深度不足时的欠载表现:执行单元等命令
图 1-47FIFO 深度不足时的欠载表现:执行单元等命令@ 本系列绘制

三、状态变更:为什么「只改一个状态」也可能很贵

3.1 状态单元的两难

前面说的命令包,本质上全是在写寄存器。那么问题来了:状态该存在哪?这背后是一组相悖的目标——状态单元要么「自己记住状态」(带状态),要么「每次下传」(无状态)。

状态单元的两难:带状态(省带宽、怕串扰) vs 每次下传(贵但干净)
图 1-48状态单元的两难:带状态(省带宽、怕串扰) vs 每次下传(贵但干净)@ 本系列绘制

带状态的好处是省:每条命令默认沿用上次的状态,不用把每个参数重发一遍。代价是串扰——一个状态改了,可能影响后续所有命令的正确性,于是前端没法只改「那一个点」。

这就是为什么「只改一个状态也可能很贵」:现代渲染是高度并行的流水线,一个共享状态位可能在几十上百个并行单元里各存了一份副本。改动一个位,要么逐个同步(慢),要么干脆冲刷(flush)整个流水段重来(更慢)。

为什么只改一个状态也可能很贵:共享状态在并行单元里多副本,同步代价高
图 1-49为什么只改一个状态也可能很贵:共享状态在并行单元里多副本,同步代价高@ 本系列绘制

3.2 四种硬件处理策略

业界在这么多年里演化出四种互相竞争的处理策略。策略一:改状态就做部分管线冲刷(Partial Flush)——老式实现常见,简单但会把流水打断。

策略一:状态变更触发部分管线冲刷,打断流水
图 1-50策略一:状态变更触发部分管线冲刷,打断流水@ 本系列绘制

策略二:单元完全无状态,状态随数据逐级下传——每个图元、每个片元带着自己的状态走,干净但带宽暴涨,几乎只在理论上完整成立。

策略二:无状态单元,状态随数据下传(带宽代价示意)
图 1-51策略二:无状态单元,状态随数据下传(带宽代价示意)@ 本系列绘制

策略三:多份状态副本 + 选择位——把几组常用状态各自存成一份「槽」,命令里用一两比特选当前用哪份。PSO 的一小部分是这种思想的产物:状态被预编译成有限份,运行时只切选择位。

策略三:多份状态副本槽 + 选择位切换
图 1-52策略三:多份状态副本槽 + 选择位切换@ 本系列绘制

策略四:寄存器重命名式的按需分配——借鉴 CPU,给状态分配「虚拟寄存器」,冲突时才真正搬移。这是最灵活也最贵的一种,主要出现在高端实现中。

策略四:寄存器重命名式按需分配,只在冲突时搬移
图 1-53策略四:寄存器重命名式按需分配,只在冲突时搬移@ 本系列绘制

四种策略摆在同一条评价维度(解码简单性 / 带宽 / 流水连续性 / 实现成本)下,取舍一目了然。没有银弹——这也是为什么「减少状态切换」永远是前端优化的头号纪律。

四种状态策略在四维上的代价对照表
图 1-54四种状态策略在四维上的代价对照表@ 本系列绘制

3.3 实现与配置:管线状态对象与预校验

显式 API 把上面的权衡收进了一个对象——PSO。它的精妙在于把「运行时校验」前移到「创建时校验」:创建 PSO 时,驱动把状态组合合法性、硬件能否实现、需要哪些子集资源一次查清,并在内部把它分解成几组可切的状态槽。

PSO 把运行时校验前移到创建时的时序对照
图 1-55PSO 把运行时校验前移到创建时的时序对照@ 本系列绘制

创建期与运行期的分工因此清晰:创建期做重校验(状态组合、资源绑定、硬件能力),运行期只做轻校验(绑定是否匹配、句柄是否有效)。这让 draw-time 的 CPU 成本大幅下降,是 D3D12/Vulkan 比旧 API CPU 开销低的核心理由。

创建期重校验 vs 运行期轻校验的分工
图 1-56创建期重校验 vs 运行期轻校验的分工@ 本系列绘制

状态对象也有命中与未命中之分:命中时驱动直接复用已编译的子状态;未命中(首次遇到该组合)时触发懒编译,这正是「替换 PSO 导致卡一下」的来源。PGO 预热就为把未命中全塞进加载期。

PSO 命中(复用)与未命中(编译)路径分差
图 1-57PSO 命中(复用)与未命中(编译)路径分差@ 本系列绘制

状态组合爆炸的量级值得量化:深度 2 态 × 混合 几十态 × 光栅 若干态 × 不同 shader 变体……乘起来是十万到百万级的潜在组合。预编译全量不现实,所以现实策略是「只编译用到的 + 懒编译」。

状态组合爆炸的量级推导:各维度状态数连乘
图 1-58状态组合爆炸的量级推导:各维度状态数连乘@ 本系列绘制

3.4 效果与量化:状态切换的代价排序

不同状态变更的代价相差悬殊,排个序对开销管理很有用:切换绑定(PSO)最贵,其次是裁剪/剪刀与目标格式,最便宜的是纯着色常量。

各类状态变更的代价排序表(相对量级)
图 1-59各类状态变更的代价排序表(相对量级)@ 本系列绘制

状态变更次数与 draw 次数是两条相互放大的曲线:draw 数上升 → 状态切换绝对次数上升 → 每切换一次的代价 × 次数 = 前端总开销暴增。这也是「合批减少 draw」省的不只是循环,而是省掉了中间夹着的状态往返。

状态变更次数与 draw 次数的相互放大关系曲线
图 1-60状态变更次数与 draw 次数的相互放大关系曲线@ 本系列绘制

把状态按变化频率分组,是工程上最实用的手法:每帧不变的(稀有态)锁一次、每批才变的(中频态)放批头、每个 draw 都变的(高频态)极致精简。这组拆分本身就是优化。

按变化频率把状态分组的做法:固定/中频/高频三档
图 1-61按变化频率把状态分组的做法:固定/中频/高频三档@ 本系列绘制

四、同步:push/pull、序列号与围栏

4.1 push 与 pull 两个模型

CPU 与 GPU 之间的「谁通知谁」,演化出两个模型。push 模型靠中断通知:GPU 完成某件事后发个中断把 CPU 撬起来。中断来得及时,但路径长、开销大,只适合高频到「必须立即处理」的事件(如错误、帧完成回调)。

push 模型:GPU 完成 → 中断 → CPU 被唤醒
图 1-62push 模型:GPU 完成 → 中断 → CPU 被唤醒@ 本系列绘制

pull 模型靠 CPU 轮询可见寄存器:CPU 主动去读一个 GPU 会更新并发布的寄存器,读到预期值就继续。轮询省掉了中断路径,但让 CPU 因为「等」而空转(Busy-Wait)。

pull 模型:CPU 轮询可见寄存器直到读到预期值
图 1-63pull 模型:CPU 轮询可见寄存器直到读到预期值@ 本系列绘制

两种模型在延迟与开销上互有取舍:push 延迟低但单次贵,pull 单次便宜但让 CPU 空转、累计也不便宜。现实实现常按事件优先级混用——高优先级走 push,普通状态走 pull。同步命令的统一抽象是「若事件 X 则 Y」:条件满足才推进,是围栏与信号量的共同内核。

同步命令的统一形式「若事件 X 则 Y」:围栏/信号量的共同内核
图 1-64同步命令的统一形式「若事件 X 则 Y」:围栏/信号量的共同内核@ 本系列绘制

4.2 序列号与围栏

命令缓冲会被反复使用,于是需要一个不变量来避免「读到旧数据」:每个命令缓冲带着一个单调递增的序列号(Sequence Number)。驱动只把「已提交到 N 号」作为推进依据,永不回退。

命令缓冲的序列号与当前序列号寄存器:单调推进
图 1-65命令缓冲的序列号与当前序列号寄存器:单调推进@ 本系列绘制

序列号的主要敌人是乱序与重读旧值:没有它,CPU 无法判断「我读到的到底是这次的完成,还是上一帧的残留」。寄存器 + 单调计数,让「该读谁」成为一个无歧义的问题。

序列号如何避免重复读取旧值:单调计数防回退
图 1-66序列号如何避免重复读取旧值:单调计数防回退@ 本系列绘制

围栏(Fence)有两种语义:时间线围栏(Timeline Fence,可被多次 signal/wait,值单调)与一次性二进制围栏(Binary Fence,一 signal 一 wait)。前者适合多队列多帧流水,后者是简单的完成信号。混用是常见 bug 源。

时间线 vs 一次性围栏的语义对照:值单调 vs 一次一等待
图 1-67时间线 vs 一次性围栏的语义对照:值单调 vs 一次一等待@ 本系列绘制

同步要是没做好,卡顿会长成很阴间的样子——不是整段掉帧,而是时间线上密集的微卡顿(Micro Stuttering):帧的平均间隔对不上用户的体验节奏,帧间隔分布图上拖着一条条尖峰尾巴。

卡顿的时间分布:微卡顿在帧间隔序列上的尖峰形态(Commons 数据图)
图 1-68卡顿的时间分布:微卡顿在帧间隔序列上的尖峰形态(Commons 数据图)@ Wikimedia Commons · ShuffyIosys(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0

4.3 实现与配置:围栏与信号量的 API 序列

一条完整的跨队列同步,在显式 API 里是一段可核对序列:创建 fence → 在目标队列上 vkQueueSignalSemaphore → 在依赖队列上 vkQueueWaitSemaphore → 主线程 vkWaitForFences。每一步的等待对象在 CPU 侧还是设备侧,决定了谁会被卡住。

一次跨队列同步的完整 API 序列:signal → wait → fence
图 1-69一次跨队列同步的完整 API 序列:signal → wait → fence@ 本系列绘制
等待发生在 CPU 侧(空转)vs 设备侧(硬件内联)的分工
图 1-70等待发生在 CPU 侧(空转)vs 设备侧(硬件内联)的分工@ 本系列绘制

帧循环里有三处经典同步点:每帧开头的下一帧命令可写、命令提交后、帧完成并取到可写目标。把三处摆成环,就能看清死锁与空转的常见位置。

帧循环里的三处同步点闭环
图 1-71帧循环里的三处同步点闭环@ 本系列绘制

同步点配置有章可循:能设备侧内联就不让 CPU 空转、能前置等待就不后置、能共用 fence 就不各建各的。这张配置清单能直接照抄到项目代码评审上。

同步点配置清单:等待对象、时机、是否设备侧
图 1-72同步点配置清单:等待对象、时机、是否设备侧@ 本系列绘制

4.4 效果与量化:同步的代价

设备侧等待与 CPU 侧等待的代价差是数量级的:设备侧是硬件内联在命令流里,几乎不额外花线程时间;CPU 侧是主线程空转浪费功耗与调度。所以「看起来一次 waitForFences,实则整个 cpu 在等」是最容易误读的坑。

设备侧等待 vs CPU 侧等待的代价对照:内联 vs 空转
图 1-73设备侧等待 vs CPU 侧等待的代价对照:内联 vs 空转@ 本系列绘制

同步过度与同步不足各有病征:过度同步 = 明明没依赖却互相等,表现为「CPU/GPU 交替空闲」的不稳定帧时间;同步不足 = 该等不等,表现为闪烁、错误帧、资源在写时被读(洞)。

过度同步(交替空转)vs 同步不足(数据洞)两种病征
图 1-74过度同步(交替空转)vs 同步不足(数据洞)两种病征@ 本系列绘制

同步开销的观测口径要知道:GPU 时间戳多数不含 CPU 等待的墙钟时间,要量同步代价得看「CPU 侧 fence 到 GPU 侧首命令」的间隔,或直接计主线程空转。

同步开销的可观测口径:CPU 侧间隔 vs GPU 侧时间戳的差别
图 1-75同步开销的可观测口径:CPU 侧间隔 vs GPU 侧时间戳的差别@ 本系列绘制

五、前端开销与小 draw 的成本结构

5.1 一次 draw 在前端要过几道

一个 draw 在前端实际要经过的环节远比「一句调用」多:参数落位 → 状态打包 → 写命令缓冲 → 推指针 → 敲铃 → 前端取包 → 解码 → 分发。每一步都在消耗。

一个 draw 在前端的完整环节链:落位到分发的每一步
图 1-76一个 draw 在前端的完整环节链:落位到分发的每一步@ 本系列绘制

关键在于拆分固定开销与规模开销:与图元数量无关的部分(解析包、改状态、管线切换)是每条 draw 都要付的「门票」;与图元数量相关的部分(顶点处理、光栅化)才是「按量计费」。这两类混在一起时,很难定位前端瓶颈。

固定开销与规模开销的分界:门票 vs 按量计费
图 1-77固定开销与规模开销的分界:门票 vs 按量计费@ 本系列绘制

提交路径上依旧保留那三个可观测点——但值得补一句:正由于固定开销存在,「draw 数下降一定是前端问题」和「draw 数上升都是前端问题」两种笼统归因都是错的,拐点在哪取决于固定开销与单 draw 图元量的相对大小。

提交路径上的可观测点,及固定/规模开销在其中的分布
图 1-78提交路径上的可观测点,及固定/规模开销在其中的分布@ 本系列绘制

5.2 小 draw 的成本结构

为什么小 draw 的单位成本比大 draw 高?因为固定开销被很少的图元摊销:一起画 1000 个图元时门票摊得很薄,一起画 2 个图元时门票占了大头。

小 draw 单位成本高:固定门票被极少图元摊销的曲线
图 1-79小 draw 单位成本高:固定门票被极少图元摊销的曲线@ 本系列绘制

套用成本模型就能看清:总成本 = 每 draw 固定开销 × draw 数 + 每图元开销 × 图元总数。前一项催你减少 draw,后一项催你减少图元。两者不是一回事。

每 draw 固定开销与每图元开销的叠加模型
图 1-80每 draw 固定开销与每图元开销的叠加模型@ 本系列绘制

于是「什么规模以下合批更划算」是一个可以算的题:当「K 个 draw 的固定开销之和」超过「把 K 个 draw 合成一个大 draw 后状态统一带来的额外负荷」时,就该合批。这个交点往往很早就到——因为单个 draw 的固定开销常是几百 cycle。

合批划算与否的判定点:固定开销之和 vs 状态统一负荷的交叉
图 1-81合批划算与否的判定点:固定开销之和 vs 状态统一负荷的交叉@ 本系列绘制

顶点复用率会显著改变上面的账:同样画 N 个三角形,索引化且高复用时每图元开销被摊薄;逐顶点重复提交时每图元开销自主外生涨。所以「合批」的收益很大程度取决于顶点索引复用率。

顶点复用率对成本曲线的影响:索引复用 vs 逐顶点重复
图 1-82顶点复用率对成本曲线的影响:索引复用 vs 逐顶点重复@ 本系列绘制

5.3 实现与配置:合批、实例化与间接提交

三种减少 draw 数的主力手段:合批(Batching,把状态相容的物体一起画)、实例化(Instancing,同一几何画 N 份)、间接提交(Indirect Draw,由设备侧决定参数)。它们吃得是同一道题的不同解法,适用边界各不相同。

合批 / 实例化 / 间接提交三种手段的对照与适用边界
图 1-83合批 / 实例化 / 间接提交三种手段的对照与适用边界@ 本系列绘制

实例化把 N 个 draw 压成 1 个:一份顶点、N 份逐实例数据,硬件在一次调用里重复 N 次。instance count、vertex stride 这些参数一并带上,画树、草、粒子全靠它。

实例化如何把 N 个 draw 压成 1 个:一份几何 + N 份实例数据
图 1-84实例化如何把 N 个 draw 压成 1 个:一份几何 + N 份实例数据@ 本系列绘制

合批不是免费的——它有状态相容性约束:同一批里物体的深度、混合、光栅、shader 必须一致,否则合不进来或要拆。这条约束是「合批 vs 质量」的张力所在。

合批的反作用:状态相容性约束决定哪些物体能合
图 1-85合批的反作用:状态相容性约束决定哪些物体能合@ 本系列绘制

三种手段都挂着一份配置清单:何时用哪种、各参数的语义、兼容性要求。直接填造型的配置表比理论更重要。

合批 / 实例化 / 间接提交的配置清单
图 1-86合批 / 实例化 / 间接提交的配置清单@ 本系列绘制

5.4 效果与量化:draw 数量的代价曲线

draw 数量与帧时间不是线性:在固定开销主导区,曲线近线性上涨;越过某点后,前端带宽或解锁成为新瓶颈,曲线变陡甚至跳频。这条曲线的形态能告诉我们该在哪停止「无限合批」。

draw 数量与帧时间的实测曲线形态:线性段与陡峭段
图 1-87draw 数量与帧时间的实测曲线形态:线性段与陡峭段@ 本系列绘制
微卡顿随时间的变化:帧时间抖动在时间轴上的分布
图 1-88微卡顿随时间的变化:帧时间抖动在时间轴上的分布@ Wikimedia Commons · ShuffyIosys(CC BY-SA 4.0) · https://commons.wikimedia.org/wiki/File:Micro_stuttering_over_time.png

拐点在哪、受什么影响:前端解码吞吐、命令包体积、PSO 切换次数共同决定它。单独看 draw 数不说明问题,得看「单位时间的命令字节流过前端多少」。

拐点位置及其受前端吞吐/包体积/切换次数的影响
图 1-89拐点位置及其受前端吞吐/包体积/切换次数的影响@ 本系列绘制

曲线读数的观测口径要固定:得用稳定的可复现场景、固定相机与负载,才谈得上 draw 数 → 帧时间 的因果,否则只是噪音。

曲线读数的观测口径:稳定场景与对照变量
图 1-90曲线读数的观测口径:稳定场景与对照变量@ 本系列绘制

六、走向现代化:bindless、间接提交与 GPU-driven

6.1 描述符管理与 bindless

传统上,CPU 必须在每个 draw 前把「这次要用哪些资源」绑定好——这是每 draw 一次的 CPU 负担。bindless(无绑定)把这层改掉:资源被放进一张按需增长的描述符表,shader 按索引取资源,CPU 只需每帧把表绑定一次。

传统每 draw 绑定 vs bindless 索引取资源的对照
图 1-91传统每 draw 绑定 vs bindless 索引取资源的对照@ 本系列绘制

于是绑定从「每 draw 一次」变成「每帧一次」——这是把高频操作变成低频操作最典型的一例。前端也因此少承受一轮又一轮的绑定校验。

绑定从每 draw 一次降为每帧一次的频率对比
图 1-92绑定从每 draw 一次降为每帧一次的频率对比@ 本系列绘制

但 bindless 把校验负担转移了:CPU 侧轻了,校验挪到设备侧运行时,由硬件在访问描述符时做有效性检查。这是「把固定开销搬到 GPU 上」的又一案例,规避它要在 shader 里做边界纪律。

bindless 对前端校验负担的转移:CPU 减负 GPU 增负
图 1-93bindless 对前端校验负担的转移:CPU 减负 GPU 增负@ 本系列绘制

多渲染目标(MRT,Multiple Render Targets)可以一次把颜色写进多个目标,它和描述符表一样是资源绑定形态的延伸——讨论绑定,绕不开它。

多渲染目标与资源绑定的形态(Commons MRT 图)
图 1-94多渲染目标与资源绑定的形态(Commons MRT 图)@ Wikimedia Commons · Faldrian(CC BY-SA 4.0) · https://creativecommons.org/licenses/by-sa/4.0

6.2 间接提交与 GPU-driven

间接提交(Indirect Draw)更进一步:绘制参数不是 CPU 现场决定的,而是由 GPU 先算好、放进缓冲区,vkCmdDrawIndirect 只带一个「去读参数」的指针。参数生成移到了设备侧。

间接提交:绘制参数由设备侧预写,CPU 只给指针
图 1-95间接提交:绘制参数由设备侧预写,CPU 只给指针@ 本系列绘制

GPU-driven 渲染把「哪些要画」也搬到设备侧:可见性剔除(Frustum/Occlusion Culling)在 GPU 上做,cull 结果直接驱动下一批间接 draw。CPU 彻底退出每 draw 决策。

GPU-driven:剔除 → 间接提交 → 绘制结果的全设备侧循环
图 1-96GPU-driven:剔除 → 间接提交 → 绘制结果的全设备侧循环@ 本系列绘制

前端在这条路径上的角色变了:从「每 draw 被 CPU 命令」变成「消费参数缓冲」。CPU 侧的 draw 调用锐减,前端压力转移成设备侧生成参数的开销。

传统每 draw CPU 命令 vs GPU-driven 设备侧自驱动的角色变化
图 1-97传统每 draw CPU 命令 vs GPU-driven 设备侧自驱动的角色变化@ 本系列绘制

代价转移到了哪里?答案是可观测的:CPU 每 draw 开销 ↓、设备的剔除与参数生成开销 ↑。它需要更严的同步(CPU 读写同一缓冲的时机)和更足的显存。这条路径解决的是「前端带宽」瓶颈,却可能制造「显存/同步」瓶颈。

代价转移图:CPU 减负对应 GPU 剔除与同步增负
图 1-98代价转移图:CPU 减负对应 GPU 剔除与同步增负@ 本系列绘制

6.3 实现与配置:前端开销相关的配置清单

前端开销相关的配置项其实可以列成一张会查错的清单,每一条都能在运行期观测读到量。

前端开销相关的完整配置清单:命令缓冲、描述符、同步点、PSO 项
图 1-99前端开销相关的完整配置清单:命令缓冲、描述符、同步点、PSO 项@ 本系列绘制

有几个配置在两代硬件间语义变了:比如同一句「depthWriteEnable=false」,在老硬件上可能导致深度测试被推迟(退化为 late-Z),而新硬件上支持拆分测试——语义变了,代价账要重算,这正是 ep12 的主题,但本篇先埋下。

关键配置项在两代硬件间的语义变化对照
图 1-100关键配置项在两代硬件间的语义变化对照@ 本系列绘制

上线前的验收项同样可以清单化:跑一遍 PSO 预编译、压测 draw 数拐点、核对同步点是否全走设备侧、用帧时间分布看是否有微卡顿尾。

上线前的验收项清单
图 1-101上线前的验收项清单@ 本系列绘制

6.4 效果与量化:现代路径相对 2011 的读数差

同一负载,在传统路径(每 draw 绑定 + CPU 提交)与现代化路径(bindless + 间接 + GPU-driven)上的读数差是实打实的:CPU 侧 draw 相关开销可降一个数量级,代价是设备侧同步与显存开销上升。

同一负载在传统 vs 现代化路径上的 CPU/GPU 开销分账对照
图 1-102同一负载在传统 vs 现代化路径上的 CPU/GPU 开销分账对照@ 本系列绘制

但要冷静归因:这些差异里哪些是前端本身的、哪些不是——bindless 省的主要是 CPU 侧命令生成,不是光栅化;GPU-driven 的收益来自剔除率与前台空闲,不是命令解码。归错因,优化就做偏。

差异归因图:CPU 命令生成 vs 设备侧 vs 剔除率的分账
图 1-103差异归因图:CPU 命令生成 vs 设备侧 vs 剔除率的分账@ 本系列绘制

结论有适用边界:这些读数脆弱地依赖场景的 draw 密集度、剔除率、目标平台。以为「bindless 一定快」是抽象化了的危险说法——它只在「draw 密集、绑定是瓶颈」时才对。

现代路径读数的适用边界:负载形态与平台依赖
图 1-104现代路径读数的适用边界:负载形态与平台依赖@ 本系列绘制

七、三条可核对的结论

7.1 可核对的结论与出处

把全篇压成三条可带走、可核对的结论:

结论一:一次 draw 的 CPU 侧成本分两半——「命令生成」与「状态管理」。 命令生成随 draw 数线性涨、随 bindless/间接锐减;状态管理由 PSO 预编译决定。出处:显式 API 呈递与 Microsoft《D3D12 CPU Efficiency Spec》。

结论一的可核对口径:CPU 侧命令生成与状态管理两段
图 1-105结论一的可核对口径:CPU 侧命令生成与状态管理两段@ 本系列绘制

结论二:前端是「状态机 + 环形缓冲 + 内存映射寄存器」三件套,瓶颈通常在解码与预取深度,而非光栅。 出处:AMD GCN 命令处理公开图、厂商命令前端架构依据、本系列 §2.5 的吞吐推理。

结论二的可核对口径:前端三件套与瓶颈定位
图 1-106结论二的可核对口径:前端三件套与瓶颈定位@ 本系列绘制

结论三:小 draw 的单位成本高,是因为固定门票少图元摊销。 合批/实例化/间接是应对它的三种手段,收益有量化拐点。出处:本系列 §5 的成本模型与曲线。

结论三的可核对口径:固定开销/图元摊销成本模型
图 1-107结论三的可核对口径:固定开销/图元摊销成本模型@ 本系列绘制

每条结论都要有观测口径,否则就是观点。把「怎么量、看哪个计数器、剔掉什么变量」写清,三条结论才有可证伪性——这是本系列每篇都收在「一笔可核对的代价账」上的原因。

三条结论各自的观测口径与可证伪条件
图 1-108三条结论各自的观测口径与可证伪条件@ 本系列绘制

也要诚实标注哪些结论在换硬件后可能不成立:命令包体积、预取深度、状态切换代价都是厂商实现细节,同一结论在新架构上可能从「红线」变成「挨打」——这正是 ep12 要逐年重估的对象。

结论的硬件依赖边界:哪些随厂商实现变化
图 1-109结论的硬件依赖边界:哪些随厂商实现变化@ 本系列绘制

7.2 与后续篇目的分工

本篇与 ep02 分好工:本篇负责「命令怎么被接收与解码」,ep02 负责「进去之后显存怎么排布」——地址空间、MMU、页表、TLB 是 ep02 的主场。

本篇与 ep02 的分工边界:命令接收 vs 显存地址空间
图 1-110本篇与 ep02 的分工边界:命令接收 vs 显存地址空间@ 本系列绘制

有几个问题必须等 ep02 才能答:命令包里的地址如何翻译、GPU 侧如何做页表 walk、TLB 未命中时的惩罚——这些是「显存与地址空间」的题,本篇只把「命令里带着地址」交棒过去。

在 ep02 接过这根指挥棒之前,先看一眼它要拆解的那片底盘——GPU 的存储层次。下层是带宽极高、延迟与功耗也极高的 GDDR 显存,经控制器汇聚进 Memory Hub;上层经 MMU 连到 GPU 计算单元,再往上是被 PCIe 与主机隔开、带宽「不那么可观」的系统内存。命令缓冲和显存资源就分布在这个层次里,谁该放在离执行单元近、还是离主机近的位置,正是 ep02 一整篇的分账主题。

GPU 的存储层次:GDDR → 控制器 → Memory Hub → MMU → PCIe 主机侧
图 1-111GPU 的存储层次:GDDR → 控制器 → Memory Hub → MMU → PCIe 主机侧@ Fabian Giesen《A trip through the Graphics Pipeline 2011》Part 2(作者 Public domain) · https://farbrausch.de/~fg/gpu/gpu_memory.jpg
必须等 ep02 才能答的问题清单及依赖关系
图 1-112必须等 ep02 才能答的问题清单及依赖关系@ 本系列绘制

八、源码与数据链接

8.1 规范与文档层级

本篇的证据链是分级的:规范条款(定义语义)→ 官方文档/白皮书(描述实现)→ 现场实测数据(验证真实性)。引用时按这个层级回溯,避免把「厂商宣传」当成「验收标准」。

规范 → 官方文档 → 实测数据的引用层级
图 1-113规范 → 官方文档 → 实测数据的引用层级@ 本系列绘制

每条结论对应的可回溯出处,本次实际命中:显式 API 呈递模型见 Vulkan 规范《Command Buffers》 与 D3D12 CPU Efficiency Spec;五层软件栈与命令处理器见主参考 《A trip through the Graphics Pipeline 2011》 Part 1–2(CC0);GPU 侧实现依据见各厂商公开架构页(链接补齐于阶段 6)。

每条结论对应的可回溯出处对照表
图 1-114每条结论对应的可回溯出处对照表@ 本系列绘制

8.2 可复现的观测手段

前端开销不是玄学,可以复现观测。手段:CPU 侧命令生成计数(Draw 数与绑定数)、前端计数器(命令字节/秒)、帧时间分布、PSO 命中统计。每一步都有对应 profiler 与 API 计数器。

可复现的前端开销观测手段清单
图 1-115可复现的前端开销观测手段清单@ 本系列绘制

各手段能证明/不能证明什么也要划清:帧时间分布能证明「有卡顿」,但不能证明卡顿在 CPU 侧还是 GPU 侧;前端计数器能证明「解码量」但测不到「同步等待」——把工具的能力边界写死,观测才不走样。

各观测手段能证明什么、不能证明什么的能力边界
图 1-116各观测手段能证明什么、不能证明什么的能力边界@ 本系列绘制

九、边界:本篇不涉及的部分

9.1 技术边界白名单

收个尾,把本篇边界钉牢:本篇不讲显存地址空间的翻译细节(ep02)、不讲 TBR/TBDR 的带宽账(《移动端》ep02/ep03)、不讲着色器核与占用率(ep03)、不讲光栅化/深度/像素后端(ep06–ep08)。这些在对应篇目有各自完整的一章。

本篇技术边界白名单:不涉及哪些主题,各自归属哪篇
图 1-117本篇技术边界白名单:不涉及哪些主题,各自归属哪篇@ 本系列绘制

与已完结两系列不重叠的原因要讲明:算法层回答「怎么算对」,约束层回答「在移动端怎么省」,本篇回答「硅片上怎么实现」——三个视角补的不是同一块空白。

与前两系列(算法层/约束层)视角互补而非重叠的关系图
图 1-118与前两系列(算法层/约束层)视角互补而非重叠的关系图@ 本系列绘制

有几条结论依赖厂商未公开文档,必须标注:如具体命令包字段布局、预取深度确值、状态切换的精确 cycle 数——这些都是 NDA 细节,本文只到机制层并如实标注为推测/未公开。

依赖厂商未公开文档的结论标注图
图 1-119依赖厂商未公开文档的结论标注图@ 本系列绘制

9.2 哪些图只能自绘,以及为什么

全篇绝大多数示意图走自绘,这不是「懒得找」,而是结构性约束:命令前端与软件栈的内部机制没有公开可得的高质量示意图——厂商只有 NDA 文档,Khronos 规范图不可再分发。可转载确实只有 13 处真图(软件栈分层、环形缓冲、命令处理流程图、die shot 等,均来自 CC 许可的 Wikimedia Commons 与 CC0 的主参考系列),检索痕迹已留证于 search-log.jsonl。

自绘图的适用范围声明与检索留痕定位
图 1-120自绘图的适用范围声明与检索留痕定位@ 本系列绘制

规范图不可再分发、厂商图不开放,意味着「照着规范画一张自己的框图」是合法且必要的路径——自绘不是降级,是许可边界下的正确姿势。凡图内文字引自家厂商图,本系列在阶段 4 统一登记许可并注明。

规范图不可再分发、厂商图不开放的说明
图 1-121规范图不可再分发、厂商图不开放的说明@ 本系列绘制

十、这一章留下的问题

10.1 三个未答问题

这一章走到头,留下三个没答完的问题,交给后续篇目:

问题一:命令包里的地址,到了 GPU 侧如何翻译? 页表、MMU、TLB 未命中惩罚——这是 ep02《显存与地址空间》的题。

问题一:命令行地址翻译留给 ep02
图 1-122问题一:命令行地址翻译留给 ep02@ 本系列绘制

问题二:一次 draw 里的顶点,到底被哪些 SIMD 单元、按什么占用率处理? 命令前端把 draw 交给着色器核之后的因果链,是 ep03 的主场。

问题二:顶点处理与占用率因果链留给 ep03
图 1-123问题二:顶点处理与占用率因果链留给 ep03@ 本系列绘制

问题三:2011 年的命令前端机制,今天变了多少、没变多少? 本篇埋的「语义变了代价账要重算」,ep12 会逐条重估。

问题三:2011→2025 机制变化留给 ep12
图 1-124问题三:2011→2025 机制变化留给 ep12@ 本系列绘制

三个问题各自卡在哪也已定位:第一个卡在地址翻译的硬件实现,第二个卡在SIMD 执行模型,第三个卡在厂商公开度——也正是全文反复标注「规范 vs 推测」的那层边界。

三个问题各自的卡点定位
图 1-125三个问题各自的卡点定位@ 本系列绘制

10.2 下篇预告与系列进度

《GPU 架构剖析》系列进度:已完成【一】从 API 到命令前端;下一篇《显存与地址空间:带宽极高,延迟也极高》将回答「命令包里的那些地址,进了 GPU 之后是怎么被找到的一排物理内存的」。

按系列进度做下索引:本篇【一】从 API 到命令前端已完成,下一篇【二】显存与地址空间直接在此打点接力,把命令包里的地址接到物理内存那一排上。

本篇反复出现的那句「规范 vs 推测」,是理解整套硬件的钥匙。下一篇我们从命令前端的出口出发,走进显存。下篇再见。

相关链接与参考资料(全篇可回溯出处):Vulkan 规范 Command Buffers / Vulkan 规范 Memory Allocation / D3D12 CPU Efficiency Spec / 主参考系列(CC0) / 厂商公开架构页(阶段 6 补全链接)

With Best Wishes. 本文由@溯光 出品,转载请注明出处。我们在硅片到像素之间,继续挖下去。