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

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

 


 

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

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

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

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

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

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

为何使用SSE指令没有性能提高多线程

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

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:大便一箩筐)
D3DXMATRIX* XXXXX::GetMatrixKey(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%的效率提高)。

 

最后得出的结论是:若是发现你所使用的编译器没有使用SSE指令执行float到int型的转换,能够手动使用内联汇编对程序进行优化

 

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

// 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

相关文章
相关标签/搜索