这章是最麻烦的内容了,涉及到很多的专业名词。笔者自己读的时候也觉得很头疼,但是人是铁饭是钢,读懂了才能构建好自己的知识体系。硬着头皮啃一下,加油捏!
最开始的介绍就快速过一下好啦,反正大家都知道GPU是个什么东西。总而言之最初GPU是不可编程的,发展到现在的可编程Shader可真是经历了好长一段时光!具体历史感兴趣的可以自己去看看。关于shader核心我们可以认为它是”a small processor that does some relatively isolated task”,也就是可以做很多独立任务的小处理器。嗯…相比于CPU是一个很聪明的超级大脑,GPU是很多很多小笨蛋。
3.1 并行处理结构
计算机最怕停机了(就是摸鱼不干活)!那么为了防止宕机,CPU是怎么做的呢?
1. CPU适合处理大量数据结构与大量code
2. CPU有多个处理器,但是处理器是串行处理的哦!啊当然,SIMD是个小例外(有关于SIMD这里简单讲下,图形处理会使用许多矩阵和向量,而使用SIMD格式就可以用一个数据结构来表示4*4的矩阵了,这样是不是很方便呢)
3. 诶,那都串行的,不是很容易延迟嘛?没关系!CPU为了减少延迟,有很多本地缓存~
4. 最开始说了,CPU是个聪明大脑,也有**分支预测、指令重排序、寄存器重命名和缓存预取**之类的方法来避免停机。不过这里就不多说啦
那GPU在干啥?
- GPU的大部分芯片区域都专用于一组大型处理器,称为着色器核心,通常有数千个。霍!好多脑子!
- GPU是一个流处理器,其中有序的类似数据集被依次处理。由于这种相似性(例如一组顶点或像素),GPU可以以大规模并行的方式处理这些数据。(牛马互不干涉)
- 这些调用尽可能独立,这样它们就不需要来自相邻调用的信息,也不共享可写内存位置。有时为了允许新的和有用的功能而打破这条规则,但是这种例外是以潜在的延迟为代价的,因为一个处理器可能等待另一个处理器完成其工作。(要是一个牛马等待另一个牛马干完才能干,那效率得多低呀)
- GPU针对吞吐量进行了优化,吞吐量定义为可以处理数据的最大速率。然而,这种快速处理是有代价的。由于专用于缓存和控制逻辑的芯片面积较少,每个着色器核心的延迟通常比CPU处理器遇到的要高得多。(说过GPU很笨的吧)
那GPU如何具体的防止停机呢?
1. 给本地寄存器增加存储空间
- 想象一个这个场景。GPU想渲染一个有2000个fragment(你可以理解为有除了颜色以外信息的像素)的图片,但是这个GPU只有一个处理器(啊好弱哦)。它要一个一个计算。计算第一个fragment的颜色的时候,寄存器有一些数据,小GPU算的很快。可过一会儿它发现要知道纹理颜色是什么,但是纹理是个独立的资源,不属于像素程序,要去和内存姐姐申请。纹理访问又很复杂,GPU就等呀等,等着这套手续办完,那不就可以摸鱼了?这可不行!
- 所以给它分配一点存储空间(你等下算像素需要这些数据是吧,统统塞给你!)这样子计算的时候就不用等待啦
2. 执行逻辑与数据分开,single instruction, multiple data(SIMD)
- 我们说过GPU有点笨,如果让它先学语文再学数学,转换的过程它可是会手忙脚乱的。所以GPU进一步通过将指令执行逻辑与数据分离来优化这一设计,这种设计被称为单指令多数据(SIMD)。在SIMD架构中,固定数量的着色器程序在同步步骤中执行相同的指令。(只干一件事,就是牛马!)SIMD的优势在于,相较于为每个程序使用独立的逻辑和分派单元,处理数据和切换所需要的硅片面积(和功耗)显著减少。
3. Wrap和wavefront
- 将我们之前的两千个fragment的例子转化为现代GPU术语,每个片段的像素着色器调用被称为线程。这种线程与CPU线程不同。它由一些内存(用于着色器的输入值)以及执行着色器所需的寄存器空间组成。使用相同着色器程序的线程会被打包成组,NVIDIA称之为warp,AMD称之为wavefront。每个warp/wavefront会由一定数量的GPU着色器核心调度执行,通常是8到64个核心,使用SIMD处理。每个线程会映射到一个SIMD通道(lane)。
- 现在已经打包好wrap了。假如有2000个线程要处理,一个wrap包含32个线程,那就得分配63个(其中一个半空)wrap。开始执行程序!发现大家同步执行相同指令,结果遇到了内存读取操作。所有线程会同时遇到它,因为相同的指令对所有线程执行。这时,读取操作会导致该warp的线程停顿,所有线程都在等待各自的(不同的)结果。(啊啊!又停机了!)为了避免停顿,系统会将当前warp换成另一个warp。这种切换速度与我们单处理器系统中的切换一样快,因为在切换warp时,每个线程内部的数据不受影响。每个线程都有自己的寄存器,且每个warp会跟踪自己正在执行的指令。换入一个新的warp,只是将核心指向一组不同的线程来执行,这没有其他额外的开销。warps会继续执行或切换,直到所有任务完成。(切换wrap有很多机制,这里就不详细讲了。)

- 着色器程序的结构也影响着效率。一个主要的因素是每个线程所需的寄存器数量。假设两千个线程可以同时驻留在GPU上。每个线程所需的寄存器越多,GPU中能够驻留的线程数就越少,从而能驻留的warp数也减少。warp数量不足的话,可能无法通过切换来缓解停顿。驻留的warps被称为“in flight”,这个数字被称为占用率。高占用率意味着有更多的warps可供处理,因此处理器空闲的可能性较小;低占用率则往往会导致性能较差。内存读取速度也会影响GPU处理速度。
4. 动态分支
- 刚才说过GPU非常笨。遇到”if”语句和循环它可是要想很久的!假设在一个着色器程序中遇到了一个“if”语句。如果所有线程都评估并选择相同的分支,那么warp可以继续执行,不用担心其他分支。然而,如果某些线程,甚至仅一个线程,选择了不同的路径,那么warp必须执行两个分支,丢弃每个线程不需要的结果。这个问题被称为线程分歧(thread divergence),在这种情况下,一些线程可能需要执行一个循环迭代或执行与其他线程不同的“if”路径,导致其他线程在此期间处于空闲状态。
总而言之,知其然才能知其所以然。我们对GPU有了一定的了解之后,才能写出更加严谨的程序!欸嘿( •̀ ω •́ )✧读到这里已经很累了!再坚持一下(其实是写累了( ̄︶ ̄)
)
3.2 GPU管线概述
这个图大家应该很熟悉了~毕竟渲染管线流程人手必备。(能摸嘛,这里简单讲讲好了)
- 绿色阶段是完全可编程的阶段。
- 黄色阶段是可配置的但不可编程的阶段,例如合并阶段可以设置不同的混合模式。
- 蓝色阶段是完全固定功能的阶段。
各个阶段的功能
1.顶点着色器(Vertex Shader):是一个完全可编程的阶段,用于实现几何处理阶段。顶点着色器处理输入的顶点数据,进行位置变换、光照计算等操作。
2. 几何着色器(Geometry Shader):也是一个完全可编程的阶段,作用于图元(点、线或三角形)的顶点。几何着色器可以用于执行每个图元的着色操作、销毁图元或创建新的图元。
3. 细分阶段Tessellation Stage和几何着色器是可选的,并非所有GPU都支持,尤其是在移动设备上。细分阶段用于将粗糙的几何体细分成更精细的部分。
4. 裁剪、三角形设置和三角形遍历阶段:这些阶段由固定功能硬件实现,通常不会被修改。裁剪阶段确定哪些部分的几何体应该被裁剪掉,三角形设置阶段负责对三角形进行预处理,而三角形遍历阶段则负责处理屏幕上每个三角形的像素。
5. 屏幕映射(Screen Mapping):影响图像最终如何映射到屏幕坐标系的过程,这也可能受到不同硬件实现的影响。
3.3 可编程的shader阶段
前提提要:别忘了刚才的可编程阶段有哪些!可以自测背一下,然后看下面。(≧∇≦)ノ
这一小结主要是关于各种shader支持的操作~
统一着色器设计(unified shader design)
- 现代着色器程序采用“统一着色器设计”(unified shader design),也就是说顶点着色器、像素着色器、几何着色器和细分着色器共享一个共同的编程模型,内部使用相同的指令集架构(ISA)。
- 实现这种模型的处理器在DirectX中被称为统一着色器核心,而具有这些核心的GPU则被称为统一着色器架构。这种架构的核心思想是着色器处理器可以在多种roles中使用,GPU可以根据需求动态分配这些处理器。例如,包含大量小三角形的网格 需要比 每个由两个三角形构成的大方形网格更多的顶点着色器处理。一个拥有独立顶点着色器核心和像素着色器核心的GPU意味着,理想的工作分配方式是预先固定的,而使用统一着色器核心的GPU可以根据负载情况自行平衡这些任务。
- (不管是顶点着色器还是像素着色器还是谁,三角形多就多分配,三角形少就少分配;但要是分开计算可就麻烦了\`(*>﹏<*)′)
数据类型
- 这里还挺重要的!不要跳!(因为笔者写DX12的时候错把uint_16写成了uint_64结果就不跑了)
- 在基本数据类型方面,通常使用32位单精度浮点标量和向量。(向量仅是着色器代码的一部分,硬件原生并不支持)现代GPU还原生支持32位整数和64位浮点数。浮点向量通常包含位置(xyzw)、法线、矩阵行、颜色(rgba)或纹理坐标(uvwq)等数据。整数通常用于表示计数器、索引或位掩码。聚合数据类型(如结构体)…
DrawCall
- 咱们写程序都是从CPU进入的,那怎么让GPU知道要干啥呢?这就不得不提到 Draw Call 了。Draw Call是调用图形API来绘制一组图元,从而触发图形管线的执行并运行其着色器。每个可编程的着色器阶段有两种类型的输入:统一输入 uniform inputs和变化输入 varying inputs
- 统一输入在整个绘制调用过程中保持不变(但可以在不同的绘制调用之间更改),而变化输入则是来自三角形顶点或光栅化的动态数据。
- 例如,像素着色器可能提供一个光源的颜色作为统一值,而三角形表面的位置信息则会随着每个像素而变化,因此是变化的。纹理是一种特殊的统一输入,曾经用于表面颜色,但现在功能可多了(以后会说)。
- 底层虚拟机为不同类型的输入和输出提供了特殊的寄存器。对于统一输入,常量寄存器数量要远大于变化寄存器。这是因为变化输入和输出需要为每个顶点或像素单独存储,因此有一个自然的限制。而统一输入则只需要存储一次,并在整个绘制调用中重复使用。
- 虚拟机还具有通用的临时寄存器,用于作为临时存储空间。所有类型的寄存器都可以通过在临时寄存器中的整数值进行数组索引。见下图
函数和操作符
- 现代GPU能够高效地执行图形计算中常见的操作。着色语言通过操作符(如 * 和 +)暴露了这些最常见的操作(如加法和乘法)。其余操作通过内建函数(如 atan()、sqrt()、log() 等)来暴露,并且这些函数经过GPU的优化。还有一些更复杂的函数,例如向量标准化与反射、叉乘、矩阵转置和行列式计算等。(呼,这里好多函数,可以去看看龙书)
流程控制
流程控制(Flow Control)是指使用分支指令来改变代码执行的流程。与流程控制相关的指令用于实现高级语言构造,如“if”和“case”语句,以及各种类型的循环。着色器支持两种类型的流程控制:
- 静态流程控制(Static Flow Control):静态流程控制的分支基于统一输入的值。这意味着在整个绘制调用中,代码的执行流程是恒定的。静态流程控制的主要优点是能够使相同的着色器在不同的情境中复用(例如,对于不同数量的光源)。由于所有着色器调用都采取相同的代码路径,因此不会出现线程分歧(thread divergence)。
- 动态流程控制(Dynamic Flow Control):动态流程控制基于变化输入的值,这意味着每个片段(fragment)可以根据不同的输入执行不同的代码路径。与静态流程控制相比,动态流程控制更加灵活和强大,但它可能会带来性能开销,尤其是在着色器调用之间代码执行流程变化剧烈时,可能导致线程分歧,进而降低效率。
3.4 可编程着色和API的发展
这里主要是历史啦,我在这里贴张图,感兴趣的朋友们可以自己去看看哦

已经很多内容啦~休息一下,我们想想目前讲了哪些。
1. 首先我们讲了GPU是一堆小笨蛋,然后讲了CPU与GPU之间的区别。
- CPU除SIMD之外使用串行处理,为了减少延迟有很多本地缓存,利用分支预测、指令重排序、寄存器重命名和缓存预取来避免停机。GPU则有数以千计的处理器被称为着色器核心,通过流处理器实现并行处理,独立处理,尽量不共享可写内存(共享内存会有延迟代价),优点是大吞吐量,缺点是缓存和控制逻辑的芯片少。
2. 然后我们讲了一下如何防止停机
- 可以通过位寄存器增加额外的存储空间、通过SIMD(执行逻辑与数据分离)、wrap或者wavefront这三种方法,另外动态分支会导致停机(所以尽量少用哦)
3. 然后我们概述了GPU渲染管线
- 这个部分大家自己画画图回忆下渲染管线的流程
4. 接下来我们讲了一下可编程shader的规则
- 可编程shader使用统一着色器设计(unified shader design)
- 数据类型可以用32位单精度浮点标量和向量、32位int和64位浮点数
- Draw Call包括统一输入、变化输入,寄存器有三种,常量寄存器、变化寄存器、临时寄存器,其中常量寄存器>变化寄存器
- shader可以使用函数和操作符
- 流程包括静态流程控制和动态流程控制,静态没有线程分歧,而动态有。
下一节我们会细讲(相对)一下渲染管线捏。