测量Direct3D状态变化
Direct3D使用很多渲染状态来控制管线中的几乎所有方面。导致状态变化的APIs包含除DrawPrimitive之外的任何函数或者方法。
状态变化很复杂因为你不能在渲染外查看状态变化的代价。这是lazy算法的结果,因为驱动和GPU会延迟工作直到必须完成。通常来说,你应该遵循下面的步骤度量一个状态变化。
测量一个简单状态的变化
使用包含DrawPrimitive的渲染序列开始,这里是测量增加SetTexture的花费的代码序列。
// Get the start counter value as shown in Example 4 // Initialize a texture array as shown in Example 4 IDirect3DTexture* texArray[2]; // Render sequence loop for(int i = 0; i < 1500; i++) { SetTexture(0, texArray[i%2]; // Force the state change to propagate to the GPU DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1); } // Get the stop counter value as shown in Example
注意到这个循环包含2个调用:SetTexture和DrawPrimitive。渲染序列循环1500次,结果和下面的相似:
Local Variable |
Number of Tics |
start |
1792998860000 |
stop |
1792998870260 |
freq |
3579545 |
再一次转换滴答为时钟周期得到:
# ticks = (stop - start) = 1792998870260 - 1792998860000 = 10,260 ticks
# cycles = machine speed * number of ticks / QPF
5,775,000 = 2 GHz * 10,260 / 3,579,545
除以循环的迭代数得到:
5,775,000 cycles / 1500 iterations = 3850 cycles for one iteration
每次循环的迭代包含一个状态变化和一个绘制调用。简单DrawPrimitive渲染序列的结果:
3850 - 1100 = 2750 cycles for SetTexture
这是增加SetTexture到渲染序列中的平均时钟周期数。同样的技术可以应用于其他状态变化。
为什么SetTexture叫做一个简单状态变化?因为设置的状态的有约束的,所以管线每次状态变化所做的工作相同。在每次SetTexture中约束两个纹理具有相同的大小和格式。
测量开关状态变化
有一些状态变化会导致渲染循环每次迭代中的图形管线施行的工作量变化。举个例子,如果启动z-testing,每个像素的颜色仅在新像素的z值和已存在的像素比对后更新渲染目标。如果z-testing关闭,不需要做这种每像素的测试,因此写入更加快速。启动或者关闭z-test状态戏剧性地改变了渲染中需要完成的工作量(CPU和GPU)。仅有的解决方法是在渲染序列中开关状态变化
举个例子,测量分析器需要如下重复两次
1. 测量DrawPrimitive的渲染序列。这个作为基准
2. 测量开关状态变化的第二个渲染序列。渲染序列循环包含:
。 设置状态为”false”的状态变化
。 类似原始序列中的DrawPrimitive
。 设置状态为”true”的状态变化
。 第二个DrawPrimitive来强制第二个状态变化实施。
3. 找到两个渲染序列的差异,这可以通过下面完成:
。 因为在新的渲染序列中有2个DrawPrimitive调用,所以对于基准DrawPrimitive序列乘以2.
。 对新序列的结果减去原始序列
。 除以2得到“false”和“true”状态变化的平均花费。
对于渲染序列使用循环技术,需要通过改变开关状态从“true”到“false”以及相反来测量改变管线状态的花费。这里“true”和“false”是非直接的,它简单地意味状态需要设置为相反的条件。这使得两个状态变化在分析器中都可以测量。当然你学到的使用查询机制的一切以及把渲染序列放到一个循环中来忽略模式转换花费的技术依然有效。
举个例子,这里是测量开关z-testing的代码序列
// Get the start counter value as shown in Example 4 // Add a loop to the render sequence for(int i = 0; i < 1500; i++) { // Precondition the pipeline state to the "false" condition SetRenderState(D3DRS_ZENABLE, FALSE); // Force the state change to propagate to the GPU DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 0)*3, 1); // Set the pipeline state to the "true" condition SetRenderState(D3DRS_ZENABLE, TRUE); // Force the state change to propagate to the GPU DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 1)*3, 1); }循环通过执行两次SetRenderState调用开关状态。第一个SetRenderState调用禁止了z-testing,而第二个SetRenderState启用了z-testing。每个SetRenderState跟随DrawPrimitive,因而和这个状态相关的工作会被驱动进行处理而不是仅仅在驱动中设置一个脏比特。
对于渲染序列来说,这些数字是合理的
Local Variable |
Number of Ticks |
start |
1792998845000 |
stop |
1792998861740 |
freq |
3579545 |
再次转换ticks为时钟周期得到:
# ticks = (stop - start) = 1792998861740 - 1792998845000 = 15,120 ticks
# cycles = machine speed * number of ticks / QPF
9,300,000 = 2 GHz * 16,740 / 3,579,545
除以循环中的迭代数得到:
9,300,000 cycles / 1500 iterations = 6200 cycles for one iteration
每个循环中的迭代包含两个状态变化和两个绘制调用。减去绘制调用(假定为1100时钟周期)剩下:
6200 - 1100 - 1100 = 4000 cycles for both state changes
这是两个状态变化的平均时钟周期数,因此每个状态变化的平均时间是:
4000 / 2 = 2000 cycles for each state change
因此,启动或禁止z-testing平均的时钟周期数是2000时钟周期数。值得一提的是QueryPerformanceCounter测量z-enable一半的时间和z-disable一半的时间。这种技术实际上是测量两个状态变化的平均时间。换句话说,你度量的是开关一个状态的时间。使用这种技术,你没有办法知道启动或者禁止时间是相同的,因为你测量的是它们的平均时间。然而,当预算一个状态的时候,这是一个合理的数字,因为一个应用程序仅仅能够通过开关状态来产生状态变化。
到现在你可以使用这些技术来分析你想要的状态变化,对不?不完全。你仍然需要非常小心那些减少需要的工作量的设计优化。在设计你的渲染序列中你需要注意两种类型的优化
小心状态变化优化
前面的章节告诉你了如何度量两种状态变化:一个简单的状态变化,通过约束使得每次迭代产生同样的工作量;一个会戏剧性改变工作量的开关状态变化。如果你使用先前的渲染序列并且增加另一个状态变化会发生什么呢?举个例子,这个例子使用z>-enable渲染序列,并且增加一个z-func测试:
// Add a loop to the render sequence for(int i = 0; i < 1500; i++) { // Precondition the pipeline state to the opposite condition SetRenderState(D3DRS_ZFUNC, D3DCMP_NEVER); // Precondition the pipeline state to the opposite condition SetRenderState(D3DRS_ZENABLE, FALSE); // Force the state change to propagate to the GPU DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 0)*3, 1); // Now set the state change you want to measure SetRenderState(D3DRS_ZFUNC, D3DCMP_ALWAYS); // Now set the state change you want to measure SetRenderState(D3DRS_ZENABLE, TRUE); // Force the state change to propagate to the GPU DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 1)*3, 1); }
z-func状态设置写入z-buffer(当前像素的z值以及深度缓存中的z值)的比较水平。D3DCMP_NEVER关闭了z-testing比较而D3DCMP_ALWAYS设置z-testing比较总是成功。
测量带DrawPrimitive的渲染序列中的这些状态变化中的一个,得到类似下面的结果:
Single State Change |
Average Number of Cycles |
D3DRS_ZENABLE only |
2000 |
或者
Single State Change |
Average Number of Cycles |
D3DRS_ZFUNC only |
600 |
但是,如果在同样的渲染序列中同时测量D3DRS_ZENABLE和D3DRS_ZFUNC,你会看到这样的结果:
Both State Changes |
Average Number of Cycles |
D3DRS_ZENABLE + D3DRS_ZFUNC |
2000 |
你可能预计这个结果会是2000加上600时钟周期,因为驱动完成两个渲染状态的所有相关工作。相反,平均是2000时钟周期。
这个结果反映了在runtime、驱动或者GPU中的状态 变化优化。在这种情况下,驱动可能会首先看到SetRenderState并且设置了一个脏的状态来延迟生效。当驱动看到第二个SetRenderState,同样的脏状态也会冗余地设置并且同样的工作也会再一次被延迟。当DrawPrimitive调用时,脏状态相关的工作最终被处理了。驱动执行这个工作一次,这意味着前面两个状态变化被驱动有效的合并了。类似的,当地二个DrawPrimitive调用时,第三和第四个状态变化被驱动有效地合并成为一个状态变化。最终的结果是对于每个绘制调用驱动和GPU仅仅处理了一个状态变化。
这是一个很好的序列相关驱动优化的例子。驱动通过设置脏状态延迟工作两次,之后实施一次工作来清除脏状态。这是一个很好的例子说明提升可以发生的类型:工作会被推迟直到绝对必须。
你怎么直到哪个状态变化会在内部设置一个脏状态并且延迟工作?仅仅测试序列(或者和驱动作者交流)。驱动周期性的更新和提升,所以优化的列表是不固定的。仅仅有一种方法来绝对清楚在给定的渲染序列、特定的硬件系列状态变化的代价,那就是测量它。
小心DrawPrimitive优化
除了状态变化优化,runtime尝试去优化驱动需要处理的绘制调用数。举个例子,考虑这些连续的绘制调用:
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 3); // Draw 3 primitives, vertices 0 - 8 DrawPrimitive(D3DPT_TRIANGLELIST, 9, 4); // Draw 4 primitives, vertices 9 - 20这个序列包含两个绘制调用, runtime 会合并成为一个调用等价于
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 7); // Draw 7 primitives, vertices 0 - 20
runtime会合并这两个绘制调用为一个调用,减少了50%的驱动工作,因为现在它仅仅需要处理一个绘制调用。
通常来说,runtime将合并2个或者多个连续的DrawPrimitive调用当:
- 图元的类型是三角列表(D3DPT_TRANGLEIST)
- 每个连续的DrawPrimitive调用必须引用顶点缓存中连续的顶点。
同样的,合并2个或者多个连续的DrawIndexedPrimitive调用的条件是:
- 图元的类型是三角列表(D3DPT_TRIANGLELIST)。
- 每个连续的DrawIndexedPritimitive调用必须引用索引缓存中连续的索引
- 每个连续的DrawIndexedPrimitive调用必须使用相同的BaseVertexIndex值
为了避免度量中合并,修改渲染序列使得图元类型不是三角形或者修改渲染序列使得没有连续的绘制调用使用连续的顶点(或者索引)。更明确地,runtime将合并满足下面两个条件的绘制调用。
l 当先前的调用是DrawPrimitive,如果接下来的绘制调用:
。 使用三角列表,并且
。 指定StartVertex = 之前 StartVertex + 之前 PrimitiveCount * 3
2 使用DrawIndexedPrimitive,如果接下来的绘制调用:
。 使用三角列表,并且
。 指定StartIndex = 之前 StartIndex + 之前 PrimitiveCount * 3,并且
。 指定BaseVertexIndex = 之前 BaseVertexIndex
这里是绘制调用的一个更加微妙的例子,当你度量时很容易忽视。假定渲染序列类似这样子:
for(int i = 0; i < 1500; i++) { SetTexture(...); DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1); }
移动SetTexure到渲染循环之外减少了SetTexture相关的工作,因为不用调用1500次了。一个不明显的次效应是DrawPrimitive的工作从1500次调用减少为1次,因为满足所有的合并绘制调用的条件。当渲染序列被处理时,runtime将处理1500次调用为一次驱动调用。通过移动一行代码,驱动的工作量戏剧性的减少了。
total work done = runtime + driver work
Example 5c: with SetTexture in the loop:
runtime work = 1500 SetTextures + 1500 DrawPrimitives
driver work = 1500 SetTextures + 1500 DrawPrimitives
Example 5d: with SetTexture outside of the loop:
runtime work = 1 SetTexture + 1 DrawPrimitive + 1499 Concatenated DrawPrimitives
driver work = 1 SetTexture + 1 DrawPrimitive
这些结果整体来说是正确的。但是原始问题的上下文非常误导。绘制优化导致驱动的工作量戏剧性的减少。这是做定制分析器常见的问题。当从渲染序列消除调用时小心避免绘制合并。事实上,这个场合是通过runtime优化提升驱动性能比的一个非常有力的例子。
现在你知道了怎么测量状态变化。首先测量DrawPrimitive,之后给序列增加额外的状态变化(在一些场合增加1个调用,而另一个场合增加2个)并测量这两个序列的差异。你可以将结果转换为滴答或者时钟周期或者时间。类似于通过QueryPerformanceCounter,测量单独的状态变化依赖于查询机制来控制指令缓冲区,并且防止状态变化到循环中来最小化模式转换的影响。这个技术测量开关一个状态的花费,因为分析器返回的是启动和禁止这个状态的平均值。
具有这些能力之后,你可以开始测量任意渲染序列并且精确地测量runtime和驱动相关的工作。这些数字之后可以用于 回答类似于“假定是CPU-限制的场合,在渲染序列中能有多少次调用仍然可以维持合理的帧率”的预算问题。
小结
这篇文章描述了如何控制指令缓冲区,使得每个单独的调用可以精确地测量。得到的测量数字可以是滴答,时钟周期或者绝对时间。它们表示每个API调用相关的runtime和驱动的工作量
以测量渲染序列中的Draw*Primitive开始,记得:
- 使用QueryPerformanceCounter来测量每个API调用的滴答数。如果你想的话,使用QueryPerformanceFrequency来转换结果为时钟周期或者时间。
- 使用查询机制在开始之前清空指令缓冲区。
- 在循环中包含渲染序列来最小化模式转换的影响。
- 使用查询机制度量GPU何时完成工作
- 小心runtime合并,这将会对完成的工作量造成很大的影响。
这给你一个DrawPrimitive性能的基准作为奠基。为了度量一个状态变化,遵循下面额外的提示:
- 增加一个状态变化到已知的渲染序列,测量新的序列。因为是在循环中做的测试,这需要设置状态两次未不同的值(类似例子中的启动或者禁止)
- 比较两个序列间的时钟周期数的差值
- 对于显著改变管线的状态变化(如SetTexture),对两个序列作差得到状态变化的实际
- 对于显著改变管线的状态变化(因此需要类似SetRenderState开关状态),对两个渲染序列作差并处以2.这将得到每个状态变化的平均时钟数。
但是小心那些会在度量中产生不可预知结果的优化。状态变化优化可能会设置脏状态来延迟工作。这将导致测量不如我们预计那样的直接。绘制调用合并戏剧性地减少驱动工作会导致错误的结论。仔细设计的渲染序列可以用来避免状态变化和绘制调用合并的发生。技巧就是通过阻止测量时发生合并,从而得到的数字是合理的预算数字。
注意没有查询机制的情况下,在应用程序中复用这些测量技术非常困难。在Direct3D 9之前仅有的可预知的方式来清空指令缓冲区是lock一个活动表面(例如渲染目标)直到等到GPU空闲。这是因为锁住一个表面强制runtime清空指令缓冲区以防在它Lock之前缓冲区中有任何渲染指令会更新表面,另外需要还等到GPU完成。尽管它相比于Direct3D 9中使用的查询机制更具有侵入性,但是这个技术是有效的。
附录
表中的数字是每个状态变化相关的runtime和驱动工作量的一个范围估计。这些估计是基于文章中提到的技术实际测量的结果。这些数字使用Direct3D 9,因而是驱动无关的。
这篇文章的技术是为了测量runtime和驱动的工作而设计。通常来说,提供一个在所有的应用中都匹配CPU和GPU性能的结果是不实际的,因为这需要穷举渲染序列。另外,GPU的性能特别难测量,因为它高度依赖渲染序列之前管线设置的状态。举个例子,启动alpha blending对CPU的工作影响很小,但是对于GPU工作量有很大的影响。因此,在这篇文章通过限制需要渲染的数据量来约束GPU的工作量最小化。这意味着这个表中的数据非常匹配CPU-限制的应用程序(对比于GPU-限制的)
鼓励使用上面的这些技术来覆盖对你最重要的场合和配置。表中的值能够用于和你得到的数字作为对比。因为每个驱动不同,产生实际数字的唯一方式是使用你的场合来得到测量结果。
API Call |
Average number of Cycles |
SetVertexDeclaration |
6500 - 11250 |
SetFVF |
6400 - 11200 |
SetVertexShader |
3000 - 12100 |
SetPixelShader |
6300 - 7000 |
SPECULARENABLE |
1900 - 11200 |
SetRenderTarget |
6000 - 6250 |
SetPixelShaderConstant (1 Constant) |
1500 - 9000 |
NORMALIZENORMALS |
2200 - 8100 |
LightEnable |
1300 - 9000 |
SetStreamSource |
3700 - 5800 |
LIGHTING |
1700 - 7500 |
DIFFUSEMATERIALSOURCE |
900 - 8300 |
AMBIENTMATERIALSOURCE |
900 - 8200 |
COLORVERTEX |
800 - 7800 |
SetLight |
2200 - 5100 |
SetTransform |
3200 - 3750 |
SetIndices |
900 - 5600 |
AMBIENT |
1150 - 4800 |
SetTexture |
2500 - 3100 |
SPECULARMATERIALSOURCE |
900 - 4600 |
EMISSIVEMATERIALSOURCE |
900 - 4500 |
SetMaterial |
1000 - 3700 |
ZENABLE |
700 - 3900 |
WRAP0 |
1600 - 2700 |
MINFILTER |
1700 - 2500 |
MAGFILTER |
1700 - 2400 |
SetVertexShaderConstant (1 Constant) |
1000 - 2700 |
COLOROP |
1500 - 2100 |
COLORARG2 |
1300 - 2000 |
COLORARG1 |
1300 - 1980 |
CULLMODE |
500 - 2570 |
CLIPPING |
500 - 2550 |
DrawIndexedPrimitive |
1200 - 1400 |