GPU流式编程

来源:互联网 发布:mac触摸板使用技巧 编辑:程序博客网 时间:2024/05/16 04:24

现代的GPU,在计算历史中第一次把数据并行、流式计算平台放入几乎每台台式计算机和笔记本电脑中。一些最近的学术派研究论文——以及本书的其他章节——演示了这些流式处理器有能力加速范围很广的应用程序,而不仅仅是它们本来所针对的实时渲染。然而,要利用这个计算能力需要使用一个对很多程序员来说是陌生的,完全不同的编程模型。本章探寻了在CPU和GPU编程之间最基本的差别之一:存储器模型,它不像传统的基于CPU的程序,基于GPU的程序对什么时间、什么地点和怎么访问存储器有一些限制。本章提供了GPU存储器模型的概览,并解释了基本的数据结构,如多维数组、结构体、列表和稀疏数组在数据并行编程模型中是如何表达的。

33.1  流式编程

现代的图形处理器能够加速的东西远不止实时计算机图形。关于在图形处理单元(GPGPU)上通用计算的新兴领域的近期工作已经显示了GPU能够加速流体力学、高阶图像处理、真实感渲染,甚至计算化学和生物等大量应用程序(Buck等2004,Harris等2004)。除了实时渲染的目的之外,使用GPU的关键是将它视为一个流式的、数据并行的计算机(参见本书的第29章,以及Dally等2004)。在GPU程序中构建计算和访问存储器的方式非常受到流式计算模型的影响。同样,在讨论基于GPU的数据结构之前首先简要这个模型。

比如GPU这样的流式处理器在编程上与CPU串行处理器方式完全不同。大多数程序员熟悉的编程模型是他们可以在程序的任何地方写到存储器中的任何位置。相反,当对一个流处理器编程时,以更加结构化的方式访问存储器。在流式模型中,程序被表示成对数据流的连续操作,如图33-1所示。流(即一个数据的有序数组)中的元素由核(即一个小的程序)中的指令处理。核在流中的每个元素上操作,并把结果写入一个输出流。

图33-1  以数据依赖图给出的流式程序

流式编程模型的约束让GPU以并行的方式运行核,因此同时可以处理许多数据元素。这种数据并行性的保证来自于确定在一个流元素上的计算不会影响在相同流的其他元素上的计算。结果,能在核计算中用到的值只能是那个核的输入和全局存储器的读取。除此之外,GPU要求核的输出是独立的:核不能执行对全局存储器的随机写入(换句话说,它们只可能写到输出流的一个流元素位置)。这个模型给予的数据并行性是GPU的速度能比串行处理器更快的基础。

节点是核,边是数据流。核并行地处理所有数据元素,并把它们的结果写给输出流。

下面有两个样例代码片断,演示了该如何把一个串行的程序转变成一个数据并行的流式程序。第一个样例演示了在串行处理器上循环过一个数组(例如,图像中的像素)。注意循环体中的指令每次只作用于一个数据元素:

for (i = 0; i < data.size(); i++)

  loopBody(data[i])

下一个例子演示了同样的代码段,用流式处理器的伪代码写成:

inDataStream = specifyInputData()

kernel = loopBody()

outDataStream = apply(kernel, inDataStream)

第一行指定了数据流。在我们的图像示例中,流是图像中的所有像素。第二行指定了计算的核,这只是来自第一个样例的循环体。最后,第三行把核应用到输入流中的所有元素,并把结果存到输出流中。在图像示例中,该操作会处理整个图像并产生一个新的,变换过的图像。

目前的GPU片段处理器还有一个超出了前面描述的流式模型的编程限制。目前的GPU片段处理器是单指令、多数据(SIMD)的并行处理器。这在传统上的意思是所有的流元素(片段)必须由相同的指令序列处理。最近的GPU(支持像素着色器 3.0 [Microsoft 2004a])稍微放松了这个严格的SIMD模型,允许使用变长循环和有限的片段级分支。然而,由于硬件基本仍是SIMD,因此分支在片段间必须有空间一致性才能高效地运行(更多的信息请参见本书的第34章)。目前的顶点处理器(顶点着色器 3.0[Microsoft 2004b])是多指令,多数据(MIMD)的机器,因此能比片段处理器更高效地运行核分支。虽然灵活性比较差,但片段处理器的SIMD结构非常高效和划算。

因为现在几乎所有的GPGPU计算都在比较强大的片段处理器上执行,所以基于GPU的数据结构必须适合于片段处理器的流和SIMD编程模型。因此,在本章中的所有数据结构都用流来表示,而且在这些数据结构上的计算都是SIMD、数据并行核的形式。

33.2  GPU存储器模型

相对于串行微处理器的主存储器、cache和寄存器,图形处理器有它们自己的存储器体系结构。然而,这个存储器体系结构是针对加速图形操作设计的,适合于流式编程模型,而不是通用、串行的计算。而且,图形API,如OpenGL和Direct3D更进一步地把这个存储器限制成只能使用图形专用的图元,如顶点、纹理和帧缓冲区。本节给了一个在当前GPU上的存储器模型的概览,以及基于流的计算如何适合于它。

33.2.1  存储器体系结构

图33-2演示了CPU和GPU的存储器体系结构。GPU的存储器系统建立了现代计算机存储器体系结构的一个分支。GPU与CPU类似,有它自己的cache和寄存器来加速计算中的数据访问。然而,GPU自己的主存储器也有它自己的存储器空间——这意味着在程序运行之前,程序员必须明确地把数据复制入GPU存储器。这个传输传统上是许多应用程序的一个瓶颈,但是新的PCI Express总线标准可能使存储器在CPU和GPU之间共享在不远的将来变得更为可行。

图33-2  CPU和GPU的存储器体系结构

33.2.2  GPU流类型

与CPU存储器不同,GPU的存储器有一些用法的限制,而且只能通过抽象的图形编程接口来访问。每个这样的抽象可以想像成不同的流类型,各流类型它自己的访问规则集。GPU程序员可以看到的3种流类型是顶点流、帧缓冲区流和纹理流。第4种流类型是片段流,在GPU里产生并完全消耗。图33-3演示了一个现代GPU的流水线,3个用户可访问的流,以在流水线中它们可以被用到的地点。

图33-3  现代GPU中的流

图注:GPU程序员可以直接访问顶点、帧缓冲区和纹理。片段流由光栅器产生,并被片段处理器消耗。它们是片段程序的输入流,但完全是在GPU内部建立和消耗的,所以对程序员来说是不能直接访问的。

1. 顶点流

顶点流通过图形API的顶点缓冲区指定。这些流容纳了顶点位置和多种逐顶点属性。这些属性传统上用作纹理坐标、颜色、法线等,但是它们可以用作顶点程序的任意输入流数据。

顶点程序不允许随机索引它们的输入顶点。直到最近,顶点流的更新还只能通过把数据从CPU传到GPU来完成。GPU不允许写入顶点流。然而,最近的API增强已经使GPU可能写入顶点流。这是通过“复制到顶点缓冲区”或“渲染到顶点缓冲区”来完成的。前一项技术,把渲染结果从帧缓冲区复制到顶点缓冲区中;在后一项技术中,渲染结果直接写到顶点缓冲区中。最近增加的GPU可写顶点流功能使GPU第一次可以把来自流水线末端的结果接入流水线的开始。

2. 片段流

片段流由光栅器产生,并被片段处理器消耗。它们是片段程序的输入流,但是因为它们完全是在图形处理器内部建立和消耗,所以它们对程序员来说是不能直接访问的。片段流的值包括来自顶点处理器的所有插值输出:位置、颜色、纹理坐标等。因为有了逐顶点的流属性,传统上使用纹理坐标的逐片段值现在可以使用任何片段程序需要的流值。

片段程序不能随机访问片段流。允许对片段流随机访问会在片段流元素之间产生依赖,因此打破了编程模型的数据并行保证。如果算法要求对片段流的随机访问,那么这个流必须首先被保存到存储器,并转换成一个纹理流。

3. 帧缓冲区流

帧缓冲区的流由片段处理器写入。它们传统上被用作容纳要显示到屏幕的像素。然而,流式GPU计算用帧缓冲区来容纳中间计算阶段的结果。除此之外,现代的GPU可以同时写入多个帧缓冲区表面(即多个RGBA缓冲区)。目前的GPU在每个渲染遍中最多可以写入16个浮点标量值(可以预计这个数值在未来的硬件上会增加)。

片段或顶点程序都不能随机地访问帧缓冲区的流。然而,CPU通过图形API可以直接读写它们。通过允许渲染遍直接写入任意一种类型的流,最近的API增强已经开始模糊帧缓冲区、顶点缓冲区和纹理的区别。

4. 纹理流

纹理是惟一一种可以被片段程序和Vertex Shader 3.0 GPU顶点程序随机访问的GPU存储器。如果程序员需要任意地索引入一个顶点、片段或帧缓冲区流,它们必须首先将它转换成一个纹理。纹理可以被CPU或GPU读取和写入。GPU通过直接渲染到纹理而非帧缓冲区,或把数据从帧缓冲区复制到纹理存储器来写入纹理。

纹理被声明为1D、2D或3D流,并分别用1D、2D或3D地址寻址。一个纹理也可以声明为一个立方图,它可以被当成6个2D纹理的数组。

33.2.3  GPU核的存储器访问

顶点和片段程序(核)是现代GPU的驮马。顶点程序在顶点流元素上操作,并将输出送到光栅器。片段程序在片段流上操作,并把输出写入帧缓冲区。这些程序的能力由它们能执行的运算操作和它们能访问的存储器所定义。GPU核中可用的多种运算操作接近于在CPU上可用的操作,然而有很多的存储器访问限制。如同先前描述的,大部分这些限制是为了保证GPU必需的并行性以维持它们的速度优势。然而,其他的限制是进化中的GPU体系结构造成的,几乎将无疑地会在将来的世代中得到解决。

下面是在支持Pixel Shader 3.0和Vertex Shader 3.0功能(Microsoft 2004 a,b)的GPU上,顶点和片段核的存储器访问规则列表:

●   没有CPU主存访问;没有磁盘访问。

●   没有GPU栈或堆。

●   随机读取全局的纹理存储器。

●   读取常量寄存器。顶点程序可以使用常数寄存器的相对索引。

●   读/写临时寄存器。

–寄存器对正在处理的流元素来说是局部的。

–没有寄存器的相对索引。

●   从流输入寄存器中流读取。

–顶点和读取顶点流。

–片段和读取片段流(光栅化的结果)。

●   流写入(只在核的尾端)。

–写入地点由该元素在流中的位置决定。
不能写入计算出的地址(即不能散列)。

–顶点核写到顶点输出流。
最多可以写入12个四分量的浮点值。

–片段核写到帧缓冲区流。
最多可以写入4个四分量的浮点值。

从前述的规则集和在33.2.2小节中描述的流类型浮现出的另一个的访问模式是:指针流(Purcell等,2002)。指针流源于可以使用任意输入流作为纹理读取地址的能力。图33-4演示了指针流只是值为存储器地址的流。如果指针流是从纹理中读取的,那么这个能力叫做依赖纹理(dependent texturing)。

图33-4  用纹理实现指针流

33.3  基于GPU的数据结构

前面几节以描述了GPU及其编程模型,现在开始深入研究在当前GPU上真实数据结构的细节。33.1节和33.2节的抽象继续适用于这里的数据结构,但是当前GPU的体系结构限制使真实的实现稍微更复杂了些。

首先描述基本结构的实现:多维数组和结构体。然后在第33.3.3和33.3.4以节中转移到更高级的结构:静态和动态的稀疏结构。

33.3.1  多维数组

如果GPU已经提供了1D、2D和3D纹理,为什么还需要介绍多维数组?这里有两个理由:首先,目前的GPU只提供2D光栅化和2D帧缓冲区,这意味着可以容易更新的惟一一种类型的纹理是2D纹理,因为它是2D帧缓冲区的一个天然替代品;其次,纹理现在在每一维的大小方面有限制,例如,目前的GPU不支持超过4 096个元素的1D纹理。如果GPU可以写入一个N-D的帧缓冲区(这里N = 1, 2, 3, …)而且没有大小限制,本节将会不太必要。

有了这些限制,带有最近相邻过滤的2D纹理是几乎所有基于GPGPU的数据结构建立的基础。注意直到最近,每个纹理维度的大小都要求是2的幂,但是这个限制终于不再出现。

下面所有的例子都使用地址变换把一个N-D数组地址转换成一个2D纹理地址。虽然最小化这个地址变换的开销是很重要的,但基于CPU的多维数组也必须执行地址变换才能查到内在的1D数组的值。事实上,GPU的纹理寻址硬件可以帮我们非常高效地进行这些变换。然而,目前的GPU确实遇到了有关这些地址变换的一个问题:浮点地址的限制。本书的第32章讨论了有关使用浮点而不是整型地址时,介绍了重要限制和错误,那些问题适用于在本节中呈现的技术。

1. 一维数组

一维数组的表示是把数据打包入一个2D纹理,如图33-5所示。当前的GPU因此可以表示最多包含16 777 216(4,096 × 4,096)个元素的一维数组。每次从一个片段或顶点程序中访问这个打包的数组时,1D地址必须被转换成一个2D纹理座标。

图33-5  一维数组打包入二维数组

图注:这么做是为了避免当前GPU上存在的1D纹理大小限制。

这个地址变换的两个版本在程序清单33-1和33-2中以Cg语法表示。程序清单33-1(Buck等,2004)的代码使用了矩形纹理,它使用整型地址,而且不支持重复贴片的地址模式。注意:CONV_CONST是基于纹理大小的常量,应该是预先计算的而不是为每个流元素重新计算。33.4节描述了一项技术,用于计算类似于CONV_CONST这样的值,在Cg编译器中这个特性叫做程序特化(program specialization)。通过这个优化,程序清单33-1可以编译成3条汇编指令。

程序清单33-1  用于整型寻址纹理的1D到2D地址变换

float2 addrTranslation_1DtoRECT( float address1D, float2 texSize )
{
  // 参数解释:
  // - address1D是一个索引入大小为N的一维数组的地址
  // - texSize是存储了这个一维数组的矩形纹理大小

  // 第1步) 把1D地址从[0,N]变换到[0,texSize.y]
  float CONV_CONST = 1.0 / texSize.x;
  float normAddr1D = address1D * CONV_CONST;

  // 第2步) 把[0,texSize.y]的1D地址转换到2D。
  // 首先执行第1步允许我们用frac和floor
  // 高效地计算2D地址,而不用取模和除法。
  // 注意y分量的floor是由纹理系统执行的。
  float2 address2D = float2( frac(normAddr1D) * texSize.x, normAddr1D );
  return address2D;
}

程序清单33-2演示了一个地址变换程例,可以用于传统的2D纹理,即使用规范化的[0, 1]地址。如果CONV_CONST是预先计算的,那么这个地址变换只占用两条片段汇编指令。它通过使用重复贴片的寻址模式(如GL_REPEAT),也有可能从程序清单33-2中除去frac指令实现。这就把这个变换减少为一条汇编指令。然而,这个优化可能在一些硬件和纹理配置上有些麻烦,所以它应该小心地在目标体系结构上测试。

二维数组

二维数组只是表示为2D纹理。它们的最大大小受限于GPU驱动。当前GPU的限制将2048 × 2048像素~4096 × 4096像素,取决于显示驱动和GPU上。这些限制可以通过图形API查询到。

程序清单33-2  用于规范化地址纹理的1D到2D地址变换

float2 addrTranslation_1Dto2D( float address1D, float2 texSize )
{
  // 参数解释:
  // - address1D是一个索引入大小为N的一维数组的地址
  // - texSize是存储了这个一维数组的2D纹理大小

  // 注意:在运行核之前预计算CONV_CONST。
  float2 CONV_CONST = float2( 1.0 / texSize.x,
                                    1.0 / (texSize.x * texSize.y );

  // 返回一个规范的2D地址(值在[0,1]之内)
  float2 normAddr2D = address1D * CONV_CONST;
  float2 address2D = float2( frac(normAddr2D.x), normAddr2D.y );
  return address2D;
)

3. 三维数组

三维数组可以以3种方式存储:3D纹理中,每一片存在一个单独的2D纹理中,或打包进单个2D纹理中(Harris等2003,Lefohn等2003,Goodnight等2003)。这些技术中的每一项都有优点和缺点,会影响在应用程序中的最佳表现。

最简单的方式是使用一个3D纹理,有两个明显的好处。第一是访问存储器时不需要地址变换计算。第二个好处是GPU的内建三线过滤可以用来容易地建立数据的高质量体渲染。缺点是在每个渲染遍中GPU最多只能更新4个片——这就需要更多的遍才能写入整个数组。

第二个解决方案是把3D纹理的每个片存储在一个单独的2D纹理中,如图33-6所示。优点是,就像内建的3D表示,数据访问时不需要地址变换,而且每个片都可以简单地更新,不需要渲染到3D纹理片的API支持。缺点是体不能再真正地随机访问,因为每个分片是一个独立的纹理。在核运行之前,程序员必须知道哪个片将被访问,因为片段和顶点程序在运行时不能动态地计算该访问哪个纹理。

图33-6  把3D纹理存储在分开的2D片中

图注:三维纹理可以把每个片存在独立的2D纹理中。优点是访问数据时不需要地址变换,以及2D片可以容易地在独立的渲染遍中更新。缺点是每个片必须在独立的渲染遍中更新,以及要访问的片号必须在核访问数据前就知道。

第三个方法是把三维数组打包进一个2D纹理中,如图33-7所示。这个解决方案对每次数据访问都需要一个地址变换,但是整个3D数组可以在一个渲染遍中更新,不需要渲染到3D纹理分片的功能。在一个渲染遍中更新整个体的能力可能会有很大的性能优势,因为当处理大的流时,GPU的并行性可以更好地发挥。另外地,不像2D片的布局,整个3D数组可以在核中随即地访问。

第二和第三个框架的缺点是GPU的内建三线过滤不能用于高性质的体数据渲染。幸运的是,备用的体渲染算法可以从这些复杂的3D纹理格式高效地渲染高性质、过滤的图像(参见本书的第41章)。

图33-7  3D数组展平到单个2D纹理

图注:三维数组可以展平到单个2D纹理(或者pbuffer),所以整个体都可以在一个渲染遍中更新,而且整个三维数组都可以随机访问。数据可能按片来展开(如同这里显示的),或以线性打包,如同程序清单33-3所描述的。

程序清单33-3中的Cg代码演示了一种地址变换形式,把3D地址转换成打包表示法的2D地址。这个打包和在程序清单33-1和33-2中使用的一维数组相同。它只是在把1D空间打包进2D纹理之前,把3D地址变换到一个大的1D空间。这个打包框架在Buck等2004中呈现。注意,这个框架可以使用程序清单33-1或33-2演示的转换,取决于数据纹理的类型(2D或矩形)。

程序清单33-3  把3D地址变换到2D地址

float2 addrTranslation_3Dto2D(float3 address3D,
                                    float3 sizeTex3D,
                                    float2 sizeTex2D)
{
  // 参数解释:
  // - address3D是一个索引入大小为sizeTex2D的三维数组的地址
  // - sizeTex2D是存储了这个三维数组的2D纹理大小

  // 第1步} 纹理大小常量(这个应该预计算!)
  float3 SIZE_CONST = float3(1.0, sizeTex3D.x,
                                  sizeTex3D.y * sizeTex3D.x);

  // 第2步} 把3D地址变换到[0, sizeTex2D.y]的1D地址
  float address1D = dot( address3D, SIZE_CONST );

  // 第3步} 把[0, texSize.y]的1D地址变换成2D,使用程序清单33-1
  // 定义的1D到2D变换函数。
  return addrTranslation_1Dto2D( address1D, sizeTex2D );
}

用于基于片的,备选的打包框架的Cg代码如程序清单33-4所示。该框架把3D纹理的片打包进2D纹理中。这个系统的一个难点是2D纹理的宽度必须被三维数组片的宽度整除。好处是内建的双线过滤可以在每个片中使用。注意,如果sliceAddr计算被存储在一个1D查找表纹理中,由address3D.z索引,而且nSlicesPerRow是预计算的,那么整个地址转换可以减少为两条指令(一个1D纹理查询和一个乘增加)。

程序清单33-4  用于把3D纹理的片打包进2D纹理的源代码

float2 addrTranslation_slice_3Dto2D( float3 address3D,
                                             float3 sizeTex3D,
                                             float2 sizeTex2D)
{
  // 注意:这个应该预计算
  float nSlicesPerRow = sizeTex2D.x / sizeTex3D.x;

  // 在片的地址空间中计算一个片的(x,y)
  float2 sliceAddr = float2( fmod(address3D.z, nSlicesPerRow),
                                  floor(address3D.z / nSlicesPerRow) );

  // 把sliceSpace地址转换成2D纹理地址
  float2 sliceSize = float2(address3D.x, address3D.y );
  float2 offset = sliceSize * sliceAddr;

  return addr3D.xy + offset;
}

注意,如果GPU支持3D帧缓冲区的3D光栅化或把纹理从2D映射到3D的能力,就没有理由把三维数组存成2D纹理了。在后一种情况中,GPU会光栅化2D、展平形式的数组,但允许程序员使用3D地址读取它。

4. 多维数组

多维数组可以打包进2D纹理,使用程序清单33-3中演示的打包框架的泛化形式(Buck等,2004)。

33.3.2  结构体

在程序清单33-5中所示的,“结构体的流”定义必须改为“流的结构体”。程序清单33-6中所示。在这个构造中,每个结构体成员都建立了一个独立的流。除此之外,结构体不能包含多于GPU的每个片段可以输出的数据。这些限制是由于片段程序不能指定它们的结果要写入的帧缓冲区地址(也就是,它们不能够执行一个散列操作)。通过把结构体指定为“流的结构体”,每个结构体成员都有相同的流索引,而因此所有成员能被一个片段程序更新。

程序清单33-5  结构体的流

// 警告:像下面例子中演示的“结构体的流”在当前的GPU上不能简单地更新。
struct Foo {
  float4 a;
  float4 b;
};

// 这个“结构体的流”很有问题
Foo foo[N];

程序清单33-6  流的结构体

// 这个“流的结构体”在当前的GPU上可以简单的更新,
// 如果每个结构体中的数据成员数目 <= GPU支持的片段输出数目。
struct Foo {
  float4 a[N];
  float4 b[N];
};

// 为每个成员定义一个独立的流
float4 Foo_a[N];
float4 Foo_b[N];

33.3.3  稀疏数据结构

到现在为止,我们讨论的数组和结构体都是密集(dense)的结构。换句话说,在数组的存储空间中所有的元素都包含有效的数据。然而,有许多问题的高效解需要使用稀疏的数据结构(如列表、树或稀疏矩阵)。稀疏的数据结构是很多优化的基于CPU的算法中的一个重要部分;如果原始的基于GPU的实现使用密集数据结构,则经常会比经过优化的CPU版本慢。除此之外,稀疏的数据结构可以减少算法的存储器需求——在GPU存储器数量有限的情况下的一个重要考虑。

尽管它们很重要,但稀疏数据结构的GPU实现是棘手的。一是更新稀疏的结构通常需要写入一个计算出的存储器地址(即散列)。第二个困难是遍历一个稀疏的结构通常需要一个数量不定的指针引用才能访问数据。这一点棘手的原因是(33.1节),目前的片段处理器是SIMD机器,必须用完全相同的指令处理大批流元素。然而,研究员已经指出一些稀疏的结构可以在目前的GPU上实现。

1. 静态稀疏结构

从描述基于GPU的静态稀疏数据结构开始,它们的结构不在GPU计算期间改变。这样的数据结构包括Purcell的光线加速栅格的三角形的列表(Purcell等,2002),以及Bolz等(2003)、Krüger和Westermann (2003)的稀疏矩阵。在这些结构中,位置和稀疏元素的数目在GPU计算的过程中是固定的。例如,在光线追踪的场景中,三角形的位置和数目并不改变。因为结构是静态的,所以它们不一定要写入计算出的存储器地址。

所有这些结构都使用一个或多个间接层来表现存储器中的稀疏结构。例如,Purcell的光线加速结构由一个三角形列表指针的3D栅格开始,如图33-8所示。3D栅格纹理包含指向三角形列表起点的指针(存储在第二个纹理中)。在三角形列表中的每个入口,依次包含了一个指针,指向存储在第三个纹理中的顶点数据。类似地,稀疏矩阵的结构使用了一个固定次数的间接层来寻找非零矩阵元素。

图33-8  Purcell的稀疏光线跟踪数据结构

图注:这些结构用两种不同的方法解决不规则访问模式的问题。第一种方法是把结构分解成块,在块中的所有元素有相同的访问模式,因此可以一起处理。第二种方法是让每个流元素在每个渲染遍处理列表中的一个项。要处理较多项的元素将会继续计算结果,而已经到达列表结尾的那些将会被关闭。

光线遍历过一个3D栅格,访问三角形列表3D纹理来获取在那个单元的三角形列表的起始位置。三角形列表入口索引入第三个纹理,那里存储了场景中所有三角形的顶点位置和纹理坐标。

分块策略的一个例子可以在Bolz 2003中找到。他们把稀疏矩阵分解成非零元素数量相同的块,并填补上相似的行,以便它们有同样的布局。遍历三角形列表的算法可以在Purcell 2002中找到,它通过条件运行来进行非均匀遍历。所有活动的光线(流元素)在每个渲染遍中处理来自它们对应列表中的一个元素。当它们到达它们的三角形列表结尾的时候,光线变为不活动的,而要处理更多三角形的光线继续运行。

注意,所有流元素都必须用相同访问模式的约束是目前GPU的SIMD运行模型的一个限制。如果未来的GPU支持MIMD流处理,那么访问不规则的稀疏数据结构可能变成非常容易。例如,像素着色器 3.0的GPU提供的有限分支已经比早先几代的GPU提供了更多用于数据结构遍历的选择。

2. 动态稀疏结构

在GPU计算期间更新的基于GPU的稀疏数据结构是研究的活跃领域。两个值得注意的例子是Purcell等2003中的光子图和Lefohn(2003、2004)中的可形变隐表面的表示。本节提供了这些数据结构的简要概览和用于更新它们的技术。

光子图是一个稀疏的、自适应的数据结构,用于估算场景中的辐照度(即到达一个表面的光)。Purcell等(2003)描述了一个完全基于GPU的光子图渲染器。为了要在GPU上建立光子图,他们发明了两种框架,用于在目前的GPU上把数据写入计算出的存储器地址(即散列)。第一项技术计算存储器地址和要存储到相应地址的数据。然后它通过在这些缓冲区上执行一个数据并行的排序操作来执行这个散列。第二项技术是模板路由,使用顶点处理器在计算出的存储器地址所定义的位置绘制大的点。它解决了冲突(当两个数据元素写入了相同的位置),通过一个创造性模板缓冲区技巧——当数据值被画在相同的片段位置时,把它们路由到同一个桶里(像素位置)。非均匀的数据访问方式类似于在前一小节描述的三角形列表的遍历。

另一个基于GPU的动态稀疏数据结构是Lefohn等(2003、2004)用来做隐表面形变的稀疏体结构。隐表面把一个3D表面定义成一个体的等值面(isosurface)(或层次集)。2D等值面的一个常见例子是在地形图上画的等高线。等高线由图中海拔相同的点组成。同样,隐3D表面表示了存储在体的体素中标量值的等值面。用这种方式表示表面从数学的角度上非常方便,允许表面自由地扭曲和改变拓扑。

隐表面的高效表示使用了一个稀疏数据结构,只存储在表面附近的体素,而不是整个体,如图33-9所示。

Lefohn等(2003、2004)描述了一项基于GPU的技术,用于把隐表面从一个形状变成另一个。随着表面的演化,表示它的稀疏数据结构也要演化(因为数据结构的大小正比于隐表面的表面积)。例如,如果初始表面是一个小的球面,而最终的形态是一个大的平面,最终形态需要的存储器显然将多于初始球面需要的。本节的剩余部分将描述这个稀疏的体结构和它如何随着表面的移动而演化。

图33-9  在GPU上的动态稀疏体数据

图注:Lefohn等(2003、2004)使用基于贴面的稀疏数据结构和算法表示可形变的隐表面。隐表面的形变计算只在包含表面贴面的稀疏集上执行。随着表面的移动,新的贴面必须被分配,而且其他的必须被释放。这个算法使用GPU来计算表面的形变,并把CPU作为存储器(贴面)管理器。GPU通过把一个位向量信息发给CPU来请求贴面分配。

这个稀疏结构是通过把3D体细分成小的2D贴面建立的(参见图33-9中标记为“A”的区域)。CPU存储在GPU纹理存储器中包含这个表面的贴面(活动的贴面)(参见图33-9中标记为“B”的区域)。GPU只在活动的贴面上执行表面的形变计算(参见图33-9中的第2步)。CPU保存活动贴面的一个映射,并为GPU计算分配/释放所需的贴面。这个框架通过把CPU用作GPU的存储器管理协处理器来解决动态更新的问题。

这个系统的一个关键组件是GPU请求CPU分配或释放贴面的方式。CPU通过读取来自GPU的一个小的编码信息(图像)来接收通信(参见图33-9中的第3和4步)。每个活动的贴面在这个图像中占有一个像素,而每个像素的值是一个位码。7位中有1位用于活动的贴面,6位用于在3D体中与它相邻的贴面。每个位的值命令CPU分配或释放相关的贴面。事实上,CPU把整个图像解释成一个位向量。GPU通过在每个活动的像素上计算存储器需求来建立这个位向量图像,然后通过使用自动mipmap生成或Buck等(2004)描述的缩减技术把请求减少到每个贴面一个。一旦CPU解码了存储器请求信息,它将分配/释放请求的贴面,并把顶点和纹理坐标的新数据集传给GPU(参见图33-9的第1步)。这些顶点数据表示了贴面的新活动集,GPU在其上计算表面的形变。

总的来说,当GPU数据结构需要更新的时候,这个动态的稀疏数据结构通过向CPU传送小信息来解决需要散列功能的问题。这个结构使用了本节一开始讨论的分块策略来统一稀疏域上的计算。框架高效的理由有几条:首先,GPU-CPU通信的数量通过使用压缩的位向量信息格式来最小化;其次,CPU只担任存储器管理,让GPU执行所有“重的”计算,注意,在整个形变中,隐表面数据只存在于GPU;最后,动态的稀疏表示使计算和存储器需求能够根据隐表面的表面积而放缩,而不是它包围盒的体积。这是一个重要的优化,如果忽略了它,就会让基于CPU的实现轻松地胜过GPU版本。

本节中描述的两种动态稀疏数据结构都遵循了数据并行数据结构的规则,它们的数据元素可以并行地独立访问。Purcell等的结构是以数据并行的方式更新的,而Lefohn等的结构的一部分更新是并行的(GPU用数据并行计算产生一个存储器的分配请求),一部分是用串行程序(CPU使用栈和队列做到每次响应一个存储器请求的数组)。由于在建立可伸缩的优化算法上的重要性,复杂的GPU兼容数据结构仍然将是一个研究的活跃领域。这些数据结构是否应该完全包含在GPU里,或者使用一个混合的CPU/GPU模型将主要取决于GPU如何发展,以及CPU和GPU沟通的速度/延迟。

33.4  性能考虑

本节将描述一些底层细节,它们对基于GPU的数据结构的总体性能会有较大的冲击。

33.4.1  依赖的纹理读取

GPU比CPU更有优势的地方之一是能够隐藏从存储器中读取cache外的值的开销。它完成这一点的方法是发射非同步的存储器读取请求,并执行其他的、非依赖的操作来填充被存储器请求占用的时间。执行多个依赖的存储器访问(用存储器访问的结果作为下一个的地址)减少了非依赖工作的数量,因此给GPU更少的机会来隐藏存储器访问延迟。同样,如果不谨慎地使用,有些类型的依赖纹理读取会引起程序的严重减速。

不是所有的依赖纹理读取都会使程序执行速度降低。公开可用的基准工具GPUBench (Fatahalian等,2004)指出,依赖纹理读取的开销完全取决于存储器访问的cache连贯性。依赖纹理读取的危险是它们很容易会产生cache不连贯的存储器访问。尽量努力地使数据结构中的依赖纹理读取是cache连贯的。维持cache连贯的技术包括把相似的计算划分到连续的块中,是用尽可能最小的查询表,以及最小化纹理依赖的级数(由此减少不连贯访问的风险)。

33.4.2  计算频度和程序特化

33.2.2小节中描述的数据流以不同的计算频度被计算。例如,顶点流比片段流频率更低。这意味着顶点流比片段流包含更少的元素。对GPU计算有效的计算频度是常量、uniform、顶点和片段。常量值在编译期已知,uniform参数是在每个核运行时只计算一次的运行期值。可以用最低可能的频率进行计算,以利用GPU中不同的计算频度。例如,如果一个片段程序访问多个相邻的纹素,如果顶点比片段少,那么在顶点程序而不是片段程序中计算那些纹素的存储器地址通常更高效。

计算频度优化的另一个例子是优化在每个数据元素上计算相同值的核代码。在GPU核运行之前,这个计算应该在CPU上以统一的频度预计算。这样的代码在前面演示的地址变换代码(程序清单33-1、33-2和33-3)中很常见。避免这样的多余计算的方法之一是手动地预计算这些统一的结果,并适当地改变核代码(包括核的参数)。另外的,更好的选项是使用Cg编译器的程序特化功能自动地执行计算。程序特化(Jones等,1993)在“运行期常数”统一设置的参数后,在运行期重新编译程序。只依赖于这些已知值的所有代码路径都被执行,而这个常数覆盖的值就存储在编译的代码中。在Cg中得到程序特化的方法是调用cgSetParameterVariability API(NVIDIA,2004)。注意,这项技术需要重新编译核,因此只适用于要特化的核在统一的参数变化之间使用了多次的情况。这项技术通常只适用于只设了一次的统一参数(“运行期常量”)。

33.4.3  Pbuffer Survival Guide

Pbuffer(或像素缓冲区)是OpenGL中离屏的帧缓冲区。除了提供浮点帧缓冲区之外,直到最近,这些特别的帧缓冲区是惟一一种OpenGL程序员可以使用的渲染到纹理功能。但pbuffer未设计成繁重地渲染到纹理的用法,而许多GPGPU应用程序要求这一点(有时有上百个pbuffer),而且当用这种方式尝试使用pbuffer的时候,存在着许多性能陷阱。

OpenGL体系结构审评委员会(ARB)现在正致力于开发一个的新机制,用于渲染可显示的帧缓冲区之外的目标(纹理、顶点数组等)。这个扩展,与顶点和像素缓冲对象扩展相结合,将会在渲染到纹理的过程中除去pbuffer的使用。然而也要包括下面一些pbuffer的诀窍,因为很多目前的应用程序广泛地使用它们。

pbuffer的基本问题是每个pbuffer包含有自己的OpenGL渲染和设备场景。在这些场景之间切换是非常昂贵的操作。用于渲染到纹理的pbuffer传统用法把一个pbuffer和一个可渲染的纹理一起使用。结果是从一个可渲染的纹理切换到另一个需要GPU场景的切换,浪费无数的应用程序性能。本节介绍了两种技术,通过避免切换场景来极大地减少改变渲染目标的开销。

这些优化中的第一个是使用多表面pbuffer。一个pbuffer表面是pbuffer的颜色缓冲区之一(如front、back、aux 0等)。每个pbuffer表面可以拥有自己的可渲染纹理,而在表面之间交换是非常迅速的。重要的是确定不要让同一个表面既作为纹理又作为渲染目标。这是一个违法的配置,而且很可能将会导致不正确的结果,因为它破坏了流编程模型的保证——核不能写入它们的输入流中。程序清单33-7的伪代码演示如何创建和使用多表面pbuffer进行多个渲染到纹理遍。

程序清单33-7  使用OpenGL中的多表面Pbuffer进行高效的渲染到纹理

void draw( GLenum readSurface, GLenum writeSurface )
{
  // 1) 把readSurface绑定到纹理
  wglBindTexImageARB(pbuffer, readSurface);

  // 2) 把渲染目标设置成writeSurface
  glDrawBuffer(writeSurface);

  // 3) 渲染
  doRenderPass(. . .);

  // 4) 释放readSurface纹理
  wglReleaseTexImageARB(pbuffer, readSurface);
}

// 1) 分配和打开多表面pbuffer(前、后、AUX缓冲区)
Pbuffer pbuff = allocateMultiPbuffer( GL_FRONT, GL_BACK, GL_AUX0, . . .);
EnableRenderContext( pbuff );

// 2) 从FRONT读取,写入BACK
draw( WGL_FRONT_ARB, GL_BACK );

// 3) 从BACK读取,写入FRONT
draw( WGL_BACK_ARB, GL_FRONT );

第二个pbuffer优化用的是打包技术,在第33.3.1小节描述成把3D纹理展平成2D纹理。把数据存储在同一个大pbuffer的多个视区中,将会进一步避免需要切换的OpenGL环境。Goodnight等(2003)在多栅格解决方案中广泛地使用这项技术。然而,和在基本的多表面pbuffer情况中一样,必须避免同时读取和写入同一个表面。

原创粉丝点击