梗概
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这种工具,所以也不太好验证。