尝试优化骨骼动画计算的意外收获——使用嵌入式汇编对float转int进行优化

本文为大便一箩筐的原创内容,转载请注明出处,谢谢:http://www.cnblogs.com/dbylk/p/4984530.html

 

 

最近一直在尝试优化公司引擎的代码,因为公司之前的客户端最大只支持1440x900的分辨率,现在想要提高到1920x1080,但是怕性能方面有问题,所以让我试试看能不能提高客户端在几百人同屏时的帧数和稳定性。

因为公司引擎目前是使用CPU计算骨骼动画(采用了D3DX提供的函数进行计算)在屏幕中存在大量角色时仍然对CPU造成了不小的压力。根据VTune的性能检测结果,300人同屏时,D3DXMatrixMultiply函数占用了5%的CPU时间(仅次于DrawCall的开销),因此我想能不能把骨骼动画的向量矩阵运算转移到GPU中进行计算(即把骨骼相关的运算写在着色器中),但通过打印公司模型的骨骼数量,发现有不少模型的骨骼数目超过了70,最多的有87根。因为公司的游戏是基于Dx9开发的,顶点着色器最多只支持256个常量寄存器,即使使用4x3矩阵也放不下这么多骨骼(除非让美术。。。)。

更何况我刚来公司没多久,在这方面的经验不足,也不能保证在公司的项目中使用GPU计算骨骼动画对性能的影响一定是正向的。因为刚来公司的时候,导师就让我写了一个播放模型动画的小demo作为训练,最开始我是用C++写骨骼动画,后来自己又用空余的时间写了一版用着色器计算骨骼动画的demo,结果性能对比发现C++计算骨骼动画的平均fps在500左右,而着色器计算骨骼动画的平均fps在4000左右,整整差了8倍!(不过这应该也跟我计算骨骼动画的C++代码效率写得不高有关,因为我当时为了训练没有使用D3DX的函数,用的是自己写的空间变换矩阵生成函数和矩阵向量乘法函数。不过根据一些论坛里的前辈提供的经验,即使使用SIMD技术对我写的函数进行优化,效率提升应该也在3倍以内,不至于造成如此大的差距。)为此我专门去问了一下导师,导师说他曾经也尝试过使用着色器计算骨骼动画,但是发现帧数反而更低了,所以一直没有对公司引擎的这一部分做修改,如果我有兴趣的话可以自己改一下,对比一下效率。然而这话说完没多久,导师就抛下我跳槽去鹅厂了,所以目前本人处于无人指导,自己胡乱摸索的阶段。。。小公司的悲哀T_T。。。

言归正转,因为导师不在公司了,所以我也没有办法知道他之前测试的时候着色器计算骨骼动画为什么会帧数更低的细节。虽然从理论和常识上来看,GPU应该比CPU更适合做这方面的运算,但考虑到造成游戏帧数并不单单只受限于CPU或GPU的运算性能,还会受到CPU/GPU内存同步、硬盘读写、网络状况等等各方面因素的制约,所以我也不敢贸然下定论。况且改写这方面的代码是一个大工程,不是一时半会就能改完的,如果写出来效率不如以前的话心血就白费了。。。为此我就想看看网上有没有前辈对“在CPU与GPU计算骨骼动画的性能”方面写过相关的分析与对比,搜到的结果一边倒——骨骼动画使用GPU计算性能更高。不过也有不少人提到了常量寄存器对骨骼数目的限制因素,想想公司项目模型的87根骨骼,我的心又凉了半截。不过很快,大便我搜到了下面这篇博客:

一种简单有效的3D模型的动画多线程方案

看完后,我觉得文章中提到的技术实用性很高,于是我便打算在公司的项目中尝试一下。考虑到既然是使用CPU计算骨骼动画,要想让性能达到极致,怎么能忘了之前提到的SIMD技术。然而大便我之前对SIMD只是有所耳闻,并没有亲自使用过,所以自然要再搜索一番 —3—)。。。

结果搜到了下面这个东西:

为什么使用SSE指令没有性能提升

上面这篇贴子的楼主在13楼回复了下面这段话:

TimothyField:
 
这个问题昨天晚上已经基本解决,因为我已经连续发了3个帖子,系统不让我继续发,所以没有及时更新。

首先要感谢polytechnic的提醒,我又仔细检查了各个部分单独花的时间,因为没有合适的工具,我是通过简单注释掉部分代码看执行时间的变化来查找疑点的。前面提到注释掉SSE代码的时候我是把相关的代码也注释掉了,现在再降低注释的粒度。

首先注意到其实性能瓶颈确实不在SSE代码部分,而是FastExp函数。这确实有点出乎意料,因为这个函数只是简单的一个查表:
inline float TFastExp::Exp(float x)
{
    int n = (int)100*x;
    return data[n];
}
由于知道x的范围,所以连参数检查都没有,这样的一个函数怎么会成为性能瓶颈呢?

我刚开始是怀疑由于n的取值变化比较大,所以data[n]的访问导致大量的cache missing,所以专门写了一段类似的程序模拟测试,数组的索引用n*31%size模拟随机访问(random函数太慢了),结果并没有发现类似的现象。

于是唯一的一个可能原因就是浮点数到整数的转换了。C编译器产生的浮点到整数的转换比较慢我是知道的,但到底多慢就没有概念了,好在验证起来比较简单,我把n设置为一个固定的整数,执行时间一下子就缩短了。

知道原因之后就比较容易解决了,现在已经把这个函数改写为:
float TFastExp::Exp(float x)
{
    int n;
    float y = 100*x;
    _asm fld y
    _asm fistp n
    return data[n];
}

用两条汇编指令,6个时钟周期搞定。(因为inline函数中不能使用嵌入式汇编,所以这个函数不再加上inline)

这个地方修改之后,程序执行时间一下降低到106秒。平均单个循环只需要150个CPU TICK左右,比较原来需要570个CPU TICK,可以猜测一个浮点数到整数的转换在C++ Builder的缺省实现中需要约400个时钟周期!!!这个猜测比较吓人,但确实是现在得到的数据暗示的结论。

再重新比较一下不使用SSE指令的C++版本算法,实测执行时间是248秒,也就是说使用SSE指令进一步循环展开后,执行时间降低到不使用SSE版本的约1/2.5。这跟原来期望差不多了。

我一看就斯巴达了,“浮点数到整数的转换”,这不跟我之前优化的那个GetMatrixKey函数有关系吗?!真是踏破铁鞋无觅处,得来全不废功夫啊,古人诚不欺我,哈哈~

 

下面要介绍一下GetMatrixKey这个函数(我会关注到它完全是因为VTune,否则这么一个小函数根本想不到它会成为性能杀手,占用的CPU时间仅次于D3DXMatrixMultiply排在第三)。在我第一次看见它的时候,它是长这样的:

// Author:Unknown (我想向这个函数的作者献上我的膝盖  —— by 大便一箩筐)

// (是的你没有看错,这个函数的作者最开始把Matrix这个单词拼错了  —— by 大便一箩筐)
D3DXMATRIX* XXXXX::GetMaxtrixKey(KeyMatrix* pArray, int nCount, int nFrame) {
    if (nCount == 0) {
        return NULL;
    }

 // 帧数一定是i, i+1, i+2…连续输出的
    int nStartFrame = static_cast<int>(pArray[0].Frame);
    if (nStartFrame >= nFrame) {
        return &pArray[0].Matrix;
    }

    if (nFrame >= GET_END_FROME_START(nCount, nStartFrame)) {
        return &pArray[nCount - 1].Matrix;
    }

    if (int(pArray[nFrame - nStartFrame].fFrame) != nFrame) {
        printf("\n帧数%d 起始帧%d 结束帧%d %s\n", nFrame, nStartFrame, int(pArray[nFrame-nStartFrame].fFrame), __FUNCTION__);
    }

    return &pArray[nFrame-nStartFrame].Matrix;
}

// 函数中用到的GET_END_FROM_START宏定义如下
#define GET_END_FROM_START(nCount, nStart) ((nCount)+(nStart)-1)

// 函数参数中用到的KeyMatrix参数定义如下
class KeyMatrix {
public:
    float fFrame;
    D3DXMATRIX Matrix;
}

首先我要吐槽一下KeyMatrix这个类:

  • 我不知道为什么表示变换的矩阵要和它对应的帧数一起存在这样一个类里(根据搜索结果fFrame除了这个函数根本没有其他地方用到)
  • 而且为什么要把帧数fFrame定义成浮点类型(根据这个函数原来有的注释:“帧数一定是i, i+1, i+2…连续输出的”,可以知道fFrame是整数,所以这里用到的时候要把它转成int)

因为KeyMatrix类被用在了动画类里,它所涉及的数据都被存在了游戏模型的动画文件里,所以贸然修改它不是一个明智的决定。

 

“GetMatrixKey这个函数的作用是根据输入的帧数nFrame返回pArray数组中对应的KeyMatrix中的矩阵。”

上面这个结论是我盯着这个函数看了几分钟以后才突然恍然大悟得出的,因为这个函数中使用了一个如此高大上的宏定义“GET_END_FROM_START”,让我一开始看见它时,认为这个函数一定完成了什么了不起的算法。结果我把宏定义套进函数,再仔细看了一看,才发现原来这个函数的主要作用就是做数组范围检查,判断nFrame有木有越界!一个检查数组越界的函数写得如此之屌(各种重复计算,在频繁调用的函数里执行不必要的打印,使用没有意义的宏定义),简直不能忍。。。

随后,我把这个函数简单地修改了一下:

// Author : 大便一箩筐 
 
inline D3DXMATRIX* XXXXX::GetMatrixKey(KeyMatrix* pArray, int nCount, int nFrame) {
    if (!nCount) {
        return NULL;
    }

    int nStartFrame = static_cast<int>(pArray[0].fFrame);
    int nIndex = nFrame - nStartFrame;
    
    if (nIndex < 0) {
        nIndex = 0;
    }

    if (nIndex >= nCount) {
        nIndex = nCount - 1;
    }

    return &pArray[nIndex].Matrix;
}

修改以后,我又用VTune测了一下性能,发现此函数的CPU时间降到了修改前的40%,虽然优化效果比较明显,但依然占用了不少的CPU时间。“这么一个简单的函数也要占用这么多CPU时间,也许是调用的次数太多了吧”,当时我是这么想的。

 

现在看了CSDN这篇贴子,原来这个函数的性能消耗主要是在不起眼的基本数据类型的转换上,着实给我上了一课。

说干就干,我马上打开了VS2013,用之前自己写的性能测试工具测了一下float到int直接转换与CSDN贴子中楼主TimothyField提供的方法的开销,结果却让我大跌眼镜——VS2013的Debug模式下编译出来的程序,在执行50,000,000次转换时,float到int直接转换消耗的时间比TimothyField提供的方法消耗时间少0.8s,也就是说直接转换的效率更高。这让我感到非常奇怪,但大便我马上注意到了TimothyField在贴子中提到他使用到编译器是C++ Builder,“也许是VS的编译器在转换中做了优化,使它比TimothyField提供的汇编更高效?”。为了确认这一点,我打开了VS调试模式中的反汇编窗口,想看看这两种转换的汇编代码有什么不同,结果发现了下面这个指令:

cvttss2si   eax,xmm0

马上打开网页搜索了一番,发现原来这个指令也是SSE指令集中的指令,它的作用是提供更高效的float到int的截断型转换。想必是C++ Builder并没有在默认转换中使用这个指令,才使得他的默认转换比fld和fistp指令更低效。

然而公司项目使用的还是VS2008编译器,会不会也没有默认使用cvttss2si指令呢?实践出真知,我马上按下了F5,打开反编译窗口查看了相应的汇编指令,发现VS2008果然没有使用cvttss2si指令,而是调用了一个float转int的函数(当时忘记给相应的汇编指令截图了,名字忘记了)。

我迫不及待地想要把公司项目中的float到int型的转换全部替换为cvttss2si指令了,不过还是再单独测试一下这个指令的效率比较好,于是我参考了VS2013直接转换的反汇编,又写了一个函数做测试:

// Author : 大便一箩筐

inline void SseAsmCast() {
    for (int i = 0; i < nCalculation; ++i) {
        float fTemp = fDenominator * fNumber;
        int iTemp;

        _asm cvttss2si eax, fTemp
        _asm mov       iTemp,eax
        
        fNumber = fTable[iTemp];
    }
}

然而测试结果却再一次让我大跌眼镜,即使使用了cvttss2si指令,消耗的时间也和使用fld + fistp指令一样,远低于VS2013默认转换的效率。为此,我考虑到可能VS2013在默认转换的过程中优化掉了临时变量iTemp与fTemp,直接使用32位寄存器(eax/ebx/ecx/edx)存储中间结果,所以才会有更高的效率,于是我又增加了几条汇编指令,避免了了iTemp与fTemp的定义:

// Author : 大便一箩筐 
 
inline void SseAsmCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        _asm {
            movss        xmm0, fNumber
            mulss        xmm0, fDenominator
            cvttss2si    eax, xmm0
            mov          ebx,fTable
            movss        xmm0,dword ptr [ebx+eax*4]
            movss        fNumber,xmm0
        }
    }
}

这一次,在Debug模式下,汇编指令的效率超越了直接转换的效率,但当我使用Release模式测试时,发现VS2013的直接转换效率再次超越了上面的汇编指令。

为此,我又查看了一下Release模式下的反汇编代码,发现VS在Release模式下还做了一个优化,那就是省略了循环体中的“movss xmm0,fNumber”这条指令,直接使用上一次循环中的xmm0寄存器参与乘法运算,为了验证,我又将汇编指令的转换函数改写如下:

// Author : 大便一箩筐

inline void SseAsmCast() {
    _asm movss        xmm0, fNumber

    for (unsigned int i = 0; i < nCalculation; ++i) {
        _asm {
            mulss        xmm0, fDenominator
            cvttss2si    eax, xmm0
            mov          ebx,fTable
            movss        xmm0,dword ptr [ebx+eax*4]
            movss        fNumber,xmm0
        }
    }
}

这一次的测试结果证实了我的想法,上面的汇编指令与VS2013编译出来的直接转换效率相当,甚至还要稍微高效一点(Release模式下50,000,000次转换节省0.03s,整个函数约有10%的效率提升)。

 

整个验证程序的源码如下:

// Author : 大便一箩筐

#pragma comment(lib, "TestUtils.lib")

#include "../TestUtils/DB_Log.h"
#include "../TestUtils/DB_Timer.h"

#include <iostream>

using namespace std;
using namespace DaBianYLK;

#define FLOAT_TO_INT(f, i) _asm fld f _asm fistp i

float* fTable = new float[1024];
const float fDenominator = 3.3f;
float fNumber = 1.0f;
const unsigned int nCalculation = 50000000;

inline void SetupFloatTable() {
    for (unsigned i = 0; i < 1023; ++i) {
        fTable[i] = (i + 1 + 0.33f) / fDenominator;
    }

    fTable[1023] = 1.0f / fDenominator;
}

inline void DirectCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        int iTemp = fDenominator * fNumber;

        fNumber = fTable[iTemp];
    }
}

inline void SseAsmCast() {
    _asm movss        xmm0, fNumber

    for (unsigned int i = 0; i < nCalculation; ++i) {
        _asm {
            mulss        xmm0, fDenominator
            cvttss2si    eax, xmm0
            mov          ebx,fTable
            movss        xmm0,dword ptr [ebx+eax*4]
            movss        fNumber,xmm0
        }
    }
}

inline void NormalAsmCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        float fTemp = fDenominator * fNumber;
        int iTemp;

        _asm fld   fTemp
        _asm fistp iTemp

        fNumber = fTable[iTemp];
    }
}

inline void StaticCast() {
    for (unsigned int i = 0; i < nCalculation; ++i) {
        int iTemp = static_cast<int>(fDenominator * fNumber);

        fNumber = fTable[iTemp];
    }
}

int main(void) {
    SetupFloatTable();

    // 直接转换
    fNumber = 1.0f;
    BENCHMARK(DirectCast, DirectCast());
    Log("FNumber : %f", fNumber);

    // Trick
    fNumber = 1.0f;
    BENCHMARK(SseAsmCast, SseAsmCast());
    Log("FNumber : %f", fNumber);

    // Trick
    fNumber = 1.0f;
    BENCHMARK(NormalAsmCast, NormalAsmCast());
    Log("FNumber : %f", fNumber);

    // 静态转换
    fNumber = 1.0f;
    BENCHMARK(StaticCast, StaticCast());
    Log("FNumber : %f", fNumber);            // 至少要输出一次fNumber,否则编译器的优化会删除执行运算的代码

    system("pause");

    return 0;
}

 

其中BENCHMARK宏是我编写的性能测试工具,它的源码开放在了我个人的GitHub:

https://github.com/DaBianYLK/TestProjects

你可能感兴趣的:(尝试优化骨骼动画计算的意外收获——使用嵌入式汇编对float转int进行优化)