• SALVIA 0.5.2优化谈


    梗概

    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了如指掌,还是要习惯于从多方面猜测,例如内存占用,对齐或紧缩,计算强度,访存密度,并行度等多个角度进行设想并用实践去验证。尽管可能会遇到很多挫折,但是,只要是直觉上有优化的余地,一般都可以找到合适的方案。

  • 相关阅读:
    用tar命令把目标压缩包解压到指定位置
    testing and Deployment
    项目第二阶段进展
    注解使用中 @RequestMapping 和 @GetMapping @PostMapping 区别
    导入项目之最多的问题
    0 for前端之数据交互
    Required String parameter 'xxxxx' is not present] 报错400
    CDI Features
    初始化数据库问题
    mysql的时区问题
  • 原文地址:https://www.cnblogs.com/lingjingqiu/p/2910104.html
Copyright © 2020-2023  润新知