CPU与GPU协同工作
阅读原文时间:2021年04月22日阅读:1

1.1、 OpenGL 的原理

1.1.1、 Linux 图形系统发展

地形渲染算法在绘图中使用了 OpenGL 去实现,OpenGL 是一个 开放的三维图形软件包,它独立于窗口系统和操作系统,以它为基础开发的应用 程序可以十分方便地在各种平台间移植。

X server 是 Linux 系统下图形接口服务器的简称,在应用程序需要系统提供 界面时,系统会建立若干个 X server,通过 X 协议跟窗口管理器交互,server 接 受用户界面输入,并能创建、映射、删除视图以及在视图中绘图。早期的显卡只 有简单显示功能,内核通过 framebuffer 驱动去对这些功能进行支持。随后显卡 上加上了 2D 加速部件,应用程序还是需要通过 X server 才能访问到加速部件。 再然后随着 3D 图形需求的发展,带有图形运算功能的显卡出现了。此时如果还 按照原来的流程:OpenGL 程序每次请求硬件都要经过 X server,那么对于大规 模的硬件加速请求,X server 会成为程序的瓶颈,难以保证复杂 3D 场景下交互 和渲染的实时性。

这时引入了 DRI(Direct rendering infrastructure)直接渲染架构。OpenGL 程 序只在一开始的时候向 X server 申请了一块空间 ,然后在每次渲染中,直接向 硬件去发出渲染命令, 并将结果返回给申请的空间中。 在渲染完成后通知 X server 对应的屏幕空间需要空间,再由 X server 去完成后续的窗口混合工作。系统内核 引入了 drm(Direct rending manager)的模块 ,提供硬件的访问通道和机制, 所以发送给 GPU 的命令会经过这个驱动传到硬件。

由下图 可知 OpenGL 向上对用户程序提供标准的标准的绘图 API,会生成 相应的硬件绘制命令,发送给内核的驱动 drm。核外的驱动由 Mesa 3D 去完成。 此时的 Mesa 并不需要 X server 为它传输命令,而是独立工作。Mesa 会帮助用户 程序缓存顶点信息、纹理信息等相应的渲染所需要的数据。并且当发生 3D 程序 切换时,为应用程序缓存当时的上下文,当程序切换回来之后,可以继续在当时 的上下文中执行 。 下面本文将从 OpenGL 的上层调用的函数接口, 渲染流水线, 及底层驱动的去对其机制进行分析。

1.1.2、 OpenGL API

首先在使用任何 OpenGL 的函数之前,我们必须创建 OpenGL Context,也 就是 OpenGL 的上下文,它存储着 OpenGL 状态变量和其他渲染相关的信息。 OpenGL 是个状态机,绘制的时候用户可以通过命令去设置一些状态,例如是否 开启深度测试是否开启混合等,改变状态会影响渲染流水线的操作。OpenGL 采 Client-Server 模型来进行编程,Client 提出渲染请求,Server 相应请求。一般 client 和 server 都在同一台电脑,但也可以通过网络连接。

OpenGL 中存在着一个三维坐标系,三个坐标满足右手定则。它的成像原 理就是将三维世界的物体和场景投影到二维平面。可以把它看成一个相机系统, OpenGL 调用者来说,只需要提供相机的位置和指向的方向,物体的位置,我 们得到相片后还可以对某一部分进行裁剪放大和缩小,然后摆放在相框中,这样 照相过程就完成了,这几个过程也是 OpenGL 成像的过程,分别对应 OpenGL 的 几个变换,视口变换,投影变换,位移变换,视点变换。程序中应该依次调用这 几个变换的 api,才能正确地完成渲染。但是实际上在运算时这几个变换的顺序 却是相反的。首先是视点变换,它确定了相机的位置朝向和正方向,然后是位移变换,这里包括平移和旋转,即计算出物体在世界坐标系中的位置。投影变换是三维世界投影到二维平面的过程。最后一个视口变换,是决定生成的图像如何在窗口上显示的过程。

1.1.3、 OpenGL 渲染流水线

本文用经典的固定管线去对 OpenGL 的渲染流水线进行分析, 并对比说明其 对应的可编程管线的各个阶段。

顶点处理:主要是计算顶点的坐标和颜色,此时如果没有光照,就是当前的 颜色,否则计算光照之后的颜色。这个对应可编程管线的顶点着色器。

图元装配和裁剪:会根据顶点与顶点之间的关系,例如是点线还是多边形进 行图元装配。然后根据视景体和用户定义的裁剪平面进行裁剪。然后进行透视出 发,并进行视口变化。视口变化之后为每个顶点生成深度值。在图元装配之前可 以进行几何着色器。

光栅化:上一步得到的仅仅是顶点。这一步将顶点插值为一个个的片段。并 计算出这些片段的深度值和颜色。这时候需要给出多边形是正面还是背面的信 息,以便做正反面的剔除。

片元着色器:当然这个阶段在固定管线是没有的,这是最后可以变成控制屏 幕上显示颜色的阶段。这里会计算出片元的颜色和深度值(虽然下一阶段也还有 可能再改变一次)。这里使用纹理映射的方式对之前的颜色进行补充,并且这里 [17] 可以终止某个片元的处理,称为片元的丢弃 。

逐片元的操作: 这个阶段将将使用深度测试和模板测试来决定一个片元是否 可见。如果它成功经过了各种测试,那么它就可以直接被绘制到帧缓存中了。 驱动对 OpenGL 的支持 上说到,用户层的驱动 Mesa 3D 将生成硬件指令发送给 drm。GPU 接收 到命令之后就可以执行。GPU 和 CPU 是连接在 PCI-E 上的两个设备,在进行数 据和命令的传输时,需要通过 PCI 总线进行传输 ,这其中需要一些机制来保 证传输的高效性和正确性。

1.2、 GPU 与 CPU 的交互

1.2.1、GPU 与 CPU 的数据交互

在实际应用中,要求 CPU 和 GPU 都能访问彼此的物理存储。原因在于:用于渲染的数据首先都是通过用户程序读入内存中,必须要让 GPU 能够读取才能 进行渲染,这需要将这部分内存映射到 GPU 的 PCI 总线地址空间;另外 CPU 有 一些直接读写 GPU 寄存器和显存的需求,例如在启动 GPU 的时候,那么这部分 显存空间需要映射到内存的地址空间。

首先是将 GPU 中的显存空间映射到 CPU 的地址空间。GPU 作为 PCI 上的 设备,和别的设备一样会将自己的一段内存映射到 PCI 地址中。每个 PCI 设备 通过 PCI 寄存器中的机制寄存器去指定映射的首地址。CPU 通过把各个设备的 存储空间映射到统一存储空间去进行访问。在系统启动时,主板会扫描各个 PCI 设备并将其空间映射到 CPU 地址中的某一段。当访问到这段存储空间时,硬件 会将这个物理地址转换为 PCI 上的总线地址。GPU 中的本地内存就是 VRAM, 当然并不是所有的 VRAM 都能映射到 PCI 的地址总线上,能映射的这部分叫可 见 VRAM,还有一部分是不能被软件访问的。

第二个方向的映射是将主存的空间映射到 GPU 的地址空间。这部分空间用 于 GPU 和 CPU 传输数据例如顶点数据索引数据用, 同时命令的传输也需要使用。 相比于 VRAM,显卡对 VRAM 的读写速度将远快于在内存中速写速度,因此 VRAM 中一般存储着经常需要访问的数据。当 VRAM 空间用完后,数据也需要 放在系统内存中。系统为 GPU 分配的内存成为 GTT(graphics translation table), 这部分内存是按需分配的, 因为一次分配大量的内存是不可能成功而且也是一种 浪费。这样就造成 GTT 内存空间不连续的问题,GPU 需要同时访问 VRAM 和 GTT 内存, 需要将它们统一编址, VRAM 地址是连续的由于是自带内存。 而 GTT 就需要通过页表建立映射关系了。

为了使 GPU 高效访问 GTT,维护了一个 GART(Graphic Address Remapping Tabel)表,存放着 GTT 中各个页面的首地址,将 GART 表首地址装载入 GPU 指定寄存器后,GPU 就可以通过寄存器从 GART 表中获取索引。我们将 GPU 使 用的地址称为 GPU 的虚拟地址, 而经过 GTT 转换后的地址称为 GPU 物理地址, 这里的物理地址等同于总线地址。所以 GPU 访存时,先判断这个地址是不是 VRAM 的地址,如果是 VRAM 的地址就直接可以访问;如果是 GTT 中的地址, 则需要减去一个基地址,然后将地址分为索引部分和偏移部分,通过索引部分在 GPU 页表中查找出物理地址的页基址, 加上原来的页内偏移就构成了对应的 PCI 空间的总线地址,这时就可以向 PCI 总线发起数据请求了。

在驱动程序中,对显存的基本管理机构是 buffer object(BO),用户空间的 数据要传输到相应的内存空间中才能被 GPU 使用。 buffer object 定义了三种类型 的显存:RADEON_GEM_DOMAIN_CPU 、RADEON_GEM_DOMAIN_GTT 、 RADEON_GEM_DOMAIN_VRAM ,分别表示显存来自内存空间、GTT 空间和 VRAM 空间。

1.2.2、 GPU 命令处理器

本小节将来介绍 GPU 中的命令处理器,及它是如何工作的。

命令处理器 CP(Command Processor)是 GPU 中用来获取从 CPU 中生成的 命令,并进行解释的处理单元。命令传入 CP 的方式有两种,一是由 CPU 通过 PCI 总线直接写入,另一种是 GPU 通过 PCI 总线从命令流中获取,命令流包括 环形缓冲区和两种间接缓冲区。 CP 会将解释命令后得到的数据放到 GPU 特定模 块中。CP 中还包含 DMA 模块,使得数据可以通过 DMA 传输。

命令和数据传输的两种方式为推和拉, 推模式中, CPU 通过 PCI 总线向 GPU 写入命令和数据。例如在初始化过程中,CPU 会设置 GPU 中一系列寄存器的状 态,然后对其进行启动。拉模式则是 GPU 主动地到内存的某个地方取命令,在 这个时候,就需要 CPU 和 GPU 进行协商去共同管理共享的存储区。拉模式中, GPU 和 CPU 独立工作,CPU 负责产生命令,GPU 负责获取命令执行,工作效 率较高。

1.2.3、 CPU 与 GPU 间的缓冲区

drm 给 GPU 发送硬件命令时,会将命令放置在一个环形缓冲区,显卡将会 从这个环形缓冲区取命令执行。它的工作原理基于一个循环队列,数据存放在一 个环装区域。CPU 会将命令从队列尾插入,GPU 会从队头取数据,队头和队尾 在不断地更新中。这个环形缓冲区存放在 GTT 内存中,以便显卡可以访问到。

CPU 和 GPU 将各自维护一些数据结构来保证环形缓冲区的正确工作。这些 数据结构有缓冲区的基地址,缓冲区大小,写指针和读指针。其中写指针和读指 针分别指向 CPU 将要写入命令的地址和 GPU 将要读取命令的地址。 当这一次的 读取命令或者写入命令结束之后,这两个指针都会往前移动。当指针到达队列的 末尾时,将会移到队列的头部继续执行。如果我们不加处理的话,就可能会发生 读指针读取了没有写入新命令的地址, 或者是写指针把命令写到了命令还没有被 处理的区域。

因而当 CPU 写入命令时,它应该通知 GPU。而 GPU 在读取命令之后,应 该通知 CPU。通知操作借由写 CPU 中的读指针副本和 GPU 中的写指针副本完 成。环形缓冲区示意图如下图。

初始阶段,读指针和写指针指向同一区域,随着程序的运行,读指针和写指 针可能会再次相遇, 这时有可能是队列空, 也有可能是队列满, 为了避免二义性, 我们避免发生队列满的情况,总在队列将满时,将命令流装入新的缓冲区。那么 驱动要时时监控着缓冲区的操作,当队列空时停止读取,当队列将满时,将读操 作挂起。

1.2.4、 命令流的同步

绘制流程中有时候会需要进行同步操作 ,例如 OpenGL 中的的 glfinish 函 数,它会将缓冲区中的指令全部送往硬件执行,并且会等待命令全部执行完之后 才返回。同步的需求其实就是在某一时刻,CPU 想确定它发送的某一条指令是 否被正确执行完了,如果正确执行完了它才继续发送命令,如果没有就会进行阻 塞操作挂起进程。

同步命令中需要构建栅栏命令组,栅栏命令组的作用就是当 GPU 执行到这 组命令之后,将会采取一定的手段去通知 CPU 命令已经执行完成了。这个命令 组中的主要命令是将某个空闲的划痕寄存器中写入栅栏序号的命令, 还有触发软 件中断的命令。当 CP 解释到划痕寄存器相关命令时,会在划痕寄存器中填写指 定的序列号。当解释到软件中断命令是,会触发软件中断,引起 CPU 端中断服 务程序的执行。 中断服务程序会会检测划痕寄存器中时候已经写入的预定的序列 号。CPU 会认为在这序号之前的绘图命令 GPU 都已经完成。并唤起等待队列中 的进程。

1.2.5、 DMA 传输

CPU 可以通过读写 PCI 总线与显卡进行数据传输, 但是这种方式通常速度较 [27] 慢且会占用 CPU 的时间。DMA(direct memory access) 允许显卡和存储器之 间直接读写数据,CPU 只需要在开始和结束时做一下处理,传输过程中 CPU 可 以做其他工作。因此使得传输数据和 CPU 可以同时工作,提高了程序的效率。

1.3、主存与显存间数据传输的优化

上面提到过,drm 中 buffer object 中的三个类型 分别决定对象放置在系统内 存中, GTT 中, 和 VRAM 中。 OpenGL 中允许程序建立 VBO, vertex buffer object (VBO)允许顶点存储在高性能的显卡内存中。对于对于那些要多次绘制的顶 点,存储在 VRAM 中总是一个好的处理方法,因为这样就避免了顶点数据在客 户端和服务器端,也就是在主存中和显存中反复传输。当然并不是所有的数据都 需要传入显存, 首先是显存的容量是有限的, 其次是那些只需要用到一次的数据, 就没有必要存储在显存中了。在这里的顶点数据我们都假设是需要绘制多次的。 也就是需要存在显存中,大部分时候这个假设是正确的。 glbufferData 函数原型 如下:

  1. void glBufferData( GLenum target, GLsizeiptr size, const GLvoid * data GLenum usage )

target 指定了 buffer object 的类型,即这块缓存的作用, size 表示了大小,data 是将要传输的数据。usage 参数将会给驱动提供一些暗示,BO 存放在哪块内存 中。驱动可以根据这个参数自行选择 BO 放置的位置,同时这个参数也并不规定 数据的真实的用途。这个参数实际上是根据数据被访问的次数去划分的,可能的 值如下:

1)stream:数据将会被改变一次而且最多只会用少数几次。

2)static:数据将会被改变一次并且会被用很多次。

3)dynamic:数据将会被改变一次并且会被使用很多次。

顶点数据的 usage 通常设置为是 static,因为它只在传输的时候被“改变”过 一次,而且会一直使用。在程序运行时发现此时顶点数据的传输路径是由 CPU 通过 PCI 数据总线在传输数据,这样的传输方式首先是传输速率很慢,其次浪费 CPU 的资源。 从上文的分析可知, 先把数据拷贝到 GTT 中, 再由 GPU 通过 DMA 的方式将数据传输到显卡会是一个速率更高的方法,也不会占用 CPU 的资源。具体的做法是创建两个 buffer (buffer 对应 BO) , 第一个 buffer 创建在 vram 中, 第二个 bufffer 创建在 GTT 中,先将数据拷入第二个 buffer,此时实际上完成的 是用户空间的数据拷贝到了 GTT 映射的地址空间,然后再将第二个 buffer 数据 拷贝到第一个 buffer 中,这一步操作是由 GPU 的 DMA 完成的。在 GTT 中建立 buffer 还是利用 usage 这个参数,当其为 STREAM 时,这个 VBO 将在 GTT 中被 创建。随后再释放掉第二个 buffer 即可。

1.4、 CPU 和 GPU 协同工作

1.4.1、 推进流程和绘制流程分析

我们先回顾下整个流程,整个过程可以分为三个过程,第一个过程中就是通 过四叉树遍历对瓦片选取过程,将需要下载的瓦片放入下载队列,将可以渲染的 瓦片装入渲染队列。第二个过程称为推进流程,主要负责地形瓦片的下载,顶点 数组的生成,像 GPU 传递传输纹理和顶点的命令,其中的瓦片的下载和其他一 些耗时都是使用多线程异步完成的, 而给 GPU 传输命令则是在主线程中完成的。 其中新生成的顶点和纹理数据会传递下一个过程。第三个过程是渲染过程,就是 将构建好的瓦片绘制出来。

因为原来推进流程中也使用了多线程,我们把处理主循环的称为主线程。我们以一帧为单位去看两个过程,推进流程由 CPU 和 GPU 共同完成,CPU 在主 线程中在推进下载线程异步下载,计算线程的计算。这部分消耗的是 CPU 的计 算资源,但是不影响主线程的速度,因为都是异步的。但是却影响瓦片从下载下 来到可用的整个进程。 因为每一帧只能有一次机会去推进这个流程。 CPU 往 GPU 中发送传输数据的命令之后,GPU 此时也会进行数据的传输,所以这个时候可 能会出现因为 GPU 中没有命令而空跑的情况。第二个过程大部分时间都需要 GPU 去完成渲染操作,在 CPU 将命令传输完成之后,CPU 就处于等待 GPU 渲 染完成的的阻塞状态。这里 GPU 和 CPU 会有一次隐式同步,只有在 GPU 处理 本次所有的绘制命令时,下一帧才能开始。这次同步是由函数 eglswapbuffer 完 成的。

1.4.2、 基于多线程

CPU 和 GPU 的协同工作 推进流程和渲染流程的耗时比大概是 1:3,那么假如我们把两个部分并行起 来, 那么 CPU 和 GPU 就可以同时处理数据。 在耗时最多的 GPU 绘制阶段, CPU 能同时推进流程并且进行命令的传输, 所以这种情况 CPU 和 GPU 都不会出现长 时间的等待对方执行完成的情况出现。 我们必须在每次四叉树遍历过程中将推进 过程进行一次同步,因为遍历过程将确定哪些瓦片需要下载,哪些瓦片可以被渲 染。

这里隐藏的一个问题是在原来算法的推进过程中,对于新生成的顶点数组, 我们会马上传递到位于渲染列表中的瓦片中,并释放原来的顶点数组。因为新生 成的顶点数组必然是更精细的,而原来的顶点数组是经过采样来的。这样造成的 一个结果是我们在渲染过程使用的数据依赖于推进过程。在并行过程中,如果我 们释放了原来的顶点数组, 那么渲染过程中使用着原来顶点数据的渲染命令就会 出错。

解决方法就是,在本帧中即使有新到的数据,我们在更新顶点数组时,并不 释放之前的数据,直到下一帧开始的同步时再释放之前的数据,那么这样渲染过 程可以同时使用新的数据和旧的数据。

1.4.3、 OpenGL 多线程的使用

由于我们这项优化基于 OpenGL 对多线程的支持,我们先回顾一下在 Linux下如何使用 OpenGL,再由此介绍 OpenGL 是如何对多线程进行支持的。

Linux 下图形化需要 X server 的支持,所以一开始需要与 Xserver 建立连接, 这个连接句柄成称为 display。 XopenDisplay 即为 Xlib 提供的建立 display 的函数, 传回 display 的结构中存着一些信息。在建立窗口之前我们需要确定根视窗,每 个视窗都有一个父视窗,所以需要先取得默认的屏幕作为根视窗。在这之后就可 以建立窗口了,此时窗口只是创建出来,而没有显示,之后调用窗口映射函数将 使得窗口显示在屏幕中。

在 OpenGL 端,首先我们也要调用函数得到可以被 OpenGL 访问的 display 句柄。然后我们会调用 eglCreateWindowSurface 去得到屏幕上的一块显示区域。 这块区域从我们刚建立的窗口程序有关。这样我们就得到了一块“画布”,我们 之后的操作都以这块 “画布” 为对象。 随后我们需要建立 OpenGL 的渲染上下文, 也就是 context,通过这个 context 我们就可以对某个显示区域进行绘制了。 eglcreateContext 函数中,允许传入另一个 context 作为参数,那么建立的两个上 下文就可以共享显存的空间, 这样的上下文可以构建任意多个。 当然这些 context 必须在同一个地址空间中,也就是它们必须要在同一个进程中,因为多个线程可 以共享进程的地址空间。最后 调用 eglmakecurrent 这个函数将 context 与当前的线程和渲染的区域绑定 。

  1. EGLBoolean eglMakeCurrent( EGLDisplay display, EGLSurface draw, EGLSurface read, EGLContext context )

这个函数接收了 EGL 的 display, GL 的上下文之外, 还有两个 “表面” (surface) 对象,分别用于读写操作,在正常渲染流程中,这两块区域通常是一样的。就是 我们往屏幕画数据,和从屏幕中取数据的地方。当第一次绑定线程之后,视点和 裁剪尺寸就和第一次的“绘制表面”绑定了,以后不会随着之后的对线程的绑定 而改变。OpenGL 规定了同一个“表面”只能绑定一个线程。

1.5、 小结

本文介绍了在 Linux 系统中三维绘图的框架。从用户层面和驱动层面对 OpenGL 绘图流程进行了分析。并且对驱动中的数据传输过程和命令流的流程进 行介绍。提出了针对数据传输的优化方法。另一方面,对 CPU 和 GPU 在交互过 程中可能产生的由于等待彼此而产生的性能瓶颈进行分析, 实现了利用渲染多线 程去减少这种等待的方法。