代码改变世界

SALVIA 0.5.2优化谈

2013-02-11 20:13 by 空明流转, ... 阅读, ... 评论, 收藏, 编辑

梗概

SALVIA 0.5.2 的优化经历是一个“跌宕起伏”的过程。这个过程的结果很简单:

在Core 2 Duo T5800(2.0GHz x 2)上,Sponza的性能提升了60%,ComplexMesh性能提升了26%。

背景

SALVIA的整个渲染流程主要是以下几部分:

  • 根据Index Buffer获得需要进行变换的顶点;
  • 将顶点利用Vertex Shader进行变换;
  • 将变换后的顶点,输出成若干个float4;
  • 将三角形光栅化。SALVIA的光栅化是将三角形拆分成4x4的像素块若干,不满的块有掩码来处理;
  • 将像素进行插值;
  • 插完值后把像素送到Pixel Shader中处理一趟;
  • 处理完的结果用Blend Shader塞到Back buffer里面去。

用于测试的场景:

  • Sponza 26万个面,20个左右的Diffuse纹理(1024x1024);
  • PartOfSponza 约200个面,4个Diffuse纹理(1024x1024);
  • ComplexMesh 两万个面,无纹理,有个能量保守的光照。

最初的版本(V1231)中,性能的主要瓶颈在插值阶段,各种耗时林林总总占了一半以上(50% - 70%)。

相比之下其他阶段对性能的影响要么有限,要么没有多少优化空间。所以最近一周的优化,就都集中在了“插值”上。

插值算法

线性的插值算法常见的实现有两种,

第一种是拿UV插值,第二种是用ddx和ddy累积。

UV是先计算像素的u和v(基本方法是用面积比,不记得就复习一下中学几何吧),然后用插值公式:

pixel = v0 * u + v1 * v + v2 * (1-u-v)

后者的步骤是选一个主顶点,然后计算这个顶点的ddx和ddy,最后用

pixel = v0 + ddx * offset_x + ddy * offset_y

计算出相应顶点。

但是在图形学中,我们还需要对插值进行透视修正,获得在3D空间中线性的插值结果。

我们将步骤修正到透视空间

先将v0,v1,v2弄到透视空间中,变成projected_v0, projected_v1, projected_v2

对于UV的插值是

pixel = ( projected_v0*u + projected_v1*v + projected_v2 * (1-u-v) ) / pixel_w

对于用ddx和ddy的累积公式是:

pixel = ( projected_v0 + projected_ddx * offset_x + projected_ddy * offset_y ) / pixel_w

插值算法的选择

何咏(Graphixer)大神之前也写了一个渲染器,比我快许多(大概是4-6倍),用的是UV;

gameKnife大神两个礼拜写成的渲染器,速度比我用五年写出来的半成品要快7倍,用的办法是Lerp到Scanline上,再Lerp到像素。

SALVIA采用了累积法:

struct transformed_vertex { float4 attributes[MAX_ATTRIBUTE_COUNT]; };
transformed_vertex projected_corner;

// 计算角点的坐标
projected_scanline_start = projected_v0 + projected_ddx * offset_x + projected_ddy * offset_y; 

// 像素的透视修正值
float inv_w; 

// 最终输出的4x4个像素
pixel_input px_in[4][4];

for(int i = 0; i < 4; ++i)
{
  projected_pixel = projected_scanline_start;
  for(int j = 0; j < 4; ++j)
  {
      // 透视空间转换到线性空间并输出到px_in中
      px_in[i][j] = unproject( projected_pixel );
     // 累加x方向上的值(透视空间)
      projected_pixel += projected_ddx;
  }
  // 累加y方向上的值(透视空间)
  projected_scanline_start += projected_ddy;
}

本轮优化之前对插值算法的优化尝试

注意那个MAX_ATTRIBUTE_COUNT,这个值通常比较大,在v1231中,它是32。

不过,显然我们不需要对所有的属性进行计算。敏敏在这里运用了一点小小的技巧进行了优化:只计算必要的属性。同时,为了减少分支的使用,他甚至用

template <int N>
void sub_n(out, v0, v1 )
{
    for(int i = 0; i < N; ++i) {
       out.attributes[i] = v0.attributes[i] – v1.attributes[i];
    }
}

并配合函数指针的方法,以促使编译器展开循环,减少分支。

不过从实际生成的汇编来看,这个部分并没有被展开到期望的形式,可能是编译器认为x86的Branch Predication性能已经足够高了吧。

这个“优化”在v1231中就已经具备了。

首轮优化:unproject函数,operator += 与 operator =

第一个Profiling是用BenchmarkPartOfSponza和Sponza跑的;unproject,operator +=和operator = 加在一起大约占用了15-20%的时间。单独的unproject

最初的实现就是普通的标量。既不要求对齐,也没有使用SIMD。

所以当然会以为用了SIMD后,优化效果会很好。于是在v1232中,中间顶点和像素输入的分配都以16字节对齐,unproj,+=和=也都使用了SSE进行了重写。

从跑分来看,PartOfSponza性能提升了20%。但是,在测试ComplexMesh和Sponza时,并未发现帧率有显著提升。

其实在进行优化之前,何咏就告诫过我,因为现代CPU的一些技术,比方说超标量啥的,四个数据宽度的SSE和标量运算相比,就只有50%的性能差距。

并且这些函数的指令已经极为简单,瓶颈也很明确的落在计算指令上。例如Unproject优化后,性能焦点就落在_mm_mul_ps上(3.7%),几无优化余地。

二轮优化:插值算法的调整

在进行第二轮优化之前同样运行了一次Profiling。因为对PartOfSponza性能基本满意,因此这次优化的目标主要在Sponza上。

排名前几位的小函数,分别是sub_n,unproj,+= 和tex2D。对sub_n例行优化后,性能没什么变化。当然,这也是意料之中的事情了。

因此,第二轮优化便着重考虑在插值算法本身上。

在优化之前,我尝试对代码成本做个粗略的评估:

在现有算法下,假设每个像素有N个需要插值的属性,则平均每个像素有

(corner)3N/16个读 + 2N/16个乘法 + 2N/16个加法 + N/16个写

(x:+=)2N个读 + N个加法 + N个写

(x:*)  N个读 + 1个标量除法 + N个乘法 + N个写

(y:+=)2N/4个读 + N/4个加法 + N/4个写

(y:=) N/4个读 + N/4个写

因为每个都是函数指针,所以这些都是优化不掉的。因此首先将一些操作合并了一下,比如把+= 和*合并以减少一下读写操作。只可惜效果也不是很明显。

 

第二刀就砍到算法的头上。因为累加本身是为了减少乘法的运用,但是这可能带来了多余的存取开销。

因此直接套用公式:

pixel = ( projected_v0 + projected_ddx * offset_x + projected_ddy * offset_y ) / pixel_w

这样就有:3N读,2N乘法,2N加法,N个乘法和N个写(假设寄存器够用的话)。不算Corner的计算成本,这样比较一下,就等于是3N/4个读,N/2+N个写,N/4个加法来换取2N个乘法的时间。本来以为作为IO瓶颈的应用,这样可以提高一些性能。不过结果证实这个买卖实在是很不划算,整体性能不增反减。

三轮优化:减少内存占用,柳暗花明

虽然所有的操作只针对已使用的属性,但是空间上还是浪费了许多。

考虑到内存占用较大也会导致一些性能损失,于是将MAX_ATTRIBUTE_COUNT从32下调到了8。

结果令人大跌眼镜。性能瞬间提升了20-30%之多。

再加上SSE也不知道为什么开始发力了,使用上之后性能大约又有了10-15%的提升。

我猜测可能是因为换页频率下降,以及Cache的命中率提升。不过手上没有VTune这种工具,所以也不太好验证。

四轮优化:精度敏感性下降的额外红利

在这轮优化之后,PartOfSponza出现了精度问题。因为视锥体的上下左右四个面都没有Clip,所以可能会出现非常大的三角形。这样累积的时候一旦起始点选择的不好,就会出现比较大的误差。在之前版本中,使用/fp: precise来减少这一问题出现的机会。但是因为使用了SSE,也让这个问题再难解决。因此我选用了一些办法,来改善精度问题。在大问题都修正以后,换用/fp: fast来编译整个SALVIA,最终也获得了0-10%左右的性能收益。

结论

对于运算和IO都密集的程序来说,优化真可能是牵一发而动全身的问题。比如在我的例子中,所有猜测是性能瓶颈的地方,都没有得到预想中的改善。

倒是在内存占用这个地方无心插柳,才得以柳暗花明,而且还让别的优化方案体现了价值。所以如果你不像qiaojie大牛那样对x86了如指掌,还是要习惯于从多方面猜测,例如内存占用,对齐或紧缩,计算强度,访存密度,并行度等多个角度进行设想并用实践去验证。尽管可能会遇到很多挫折,但是,只要是直觉上有优化的余地,一般都可以找到合适的方案。