虚幻引擎4:到底是使用ToString() 还是不使用ToString() ...等等,这个问题是什
译者: 崔国军(飞扬971) 审校:王磊(未来的未来)
今天在这里提出的问题是:我们可以让ToString()运行的更快一点么?下面这个示例代码是我们今天讨论问题的起点:
1 2 3 4 5 6 | void FName::ToString(FString& Out) const { const FNameEntry* const NameEntry = GetDisplayNameEntry(); Out.Empty( NameEntry->GetNameLength() 6); AppendString(Out); } |
看看这段代码,你可能会认为它会很难进行优化。。。因为这毕竟几乎不是一个复杂的功能。。。
但是,是的。我们可以让这段代码运行的更快。事实证明大约快2.4倍。
TOSTRING()这个函数具体做了什么?
在进入具体的复杂实现之前,让我来给你简单的介绍一下FNames。 在最简单的术语中,你可以考虑一个FName被分成两部分,前面是一个字符串,它被插入到NameTable中,在结尾处有一个可选的数字(当然,字符串部分也可以包含数字)。它们被这样进行处理,以便使得当字符串部分被重用或是数字部分被更改的时候,可以得到最优的效果。。。举个简单的例子来说,如果你有这么一些东西:
This_Is_A_Really_Long_Name_For_An_Object_But_We_Dont_Care_Because_FNAME_1
This_Is_A_Really_Long_Name_For_An_Object_But_We_Dont_Care_Because_FNAME_2
…
This_Is_A_Really_Long_Name_For_An_Object_But_We_Dont_Care_Because_FNAME_10000
那么它不会吃掉成吨的存储空间。
“This_Is_A_Really_Long_Name_For_An_Object_But_We_Dont_Care_Because_FNAME” 被插入到NameTable中最后的地方,让每个FName可以通过一个数字来有效地查找NameTable这个表。
然后,通过将字符串部分复制并附加数字(如果有的话),并使用下划线分隔这两个部分,来从FName类型转换为FString类型。而这正是ToString()的作用。
对TOSTRING()进行分析
让我们看看ToString()的源代码,注意在第三行、第四行和第五行的每一行都 调用一个函数。对于今天的目的而言,我们可以忽略第一行源代码。 GetDisplayNameEntry()可以进行优化,但是这个事情超出了我们在这里的范围。
让我们看下第4行。。。首先,注意“ 6” 硬编码数字永远不是一个好主意 - 特别是当他们没有备份一个注释来解释这个硬编码数字用途的时候。但无论如何,让我们现在原谅这个事情-我们甚至会原谅“6”针对这里的意图而言并不总是一个足够大的数字。但是无论如何,这行源代码调用了GetNameLength()函数:
1 2 3 4 5 6 7 8 9 10 11 | int32 FNameEntry::GetNameLength() const { if ( IsWide() ) { return FCStringWide::Strlen( WideName ); } else { return FCStringAnsi::Strlen( AnsiName ); } } |
通过一些跳转,最终有效地调用strlen()或是类似的一个函数。
最后,在源代码的第5行,我们有AppendString():
1 2 3 4 5 6 7 8 9 10 | void FName::AppendString(FString& Out) const { const FNameEntry* const NameEntry = GetDisplayNameEntry(); NameEntry->AppendNameToString( Out ); if (GetNumber() != NAME_NO_NUMBER_INTERNAL) { Out = TEXT( "_" ); Out.AppendInt(NAME_INTERNAL_TO_EXTERNAL(GetNumber())); } } |
需要注意的是,我们再次调用了GetDisplayNameEntry()函数。 但是这真的不是一个巨大的问题,因为这个函数比较廉价。
但是。。。 AppendString()函数调用到了AppendNameToString()函数和AppendInt()函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | void FNameEntry::AppendNameToString( FString& String ) const { if ( IsWide() ) { String = WideName; } else { String = AnsiName; } } void FString::AppendInt( int32 InNum ) { int64 Num = InNum; // This avoids having to deal with negating -MAX_int32-1 const TCHAR* NumberChar[11] = { TEXT( "0" ), TEXT( "1" ), TEXT( "2" ), TEXT( "3" ), TEXT( "4" ), TEXT( "5" ), TEXT( "6" ), TEXT( "7" ), TEXT( "8" ), TEXT( "9" ), TEXT( "-" ) }; bool bIsNumberNegative = false ; TCHAR TempNum[16]; // 16 is big enough int32 TempAt = 16; // fill the temp string from the top down. // Correctly handle negative numbers and convert to positive integer. if ( Num < 0 ) { bIsNumberNegative = true ; Num = -Num; } TempNum[--TempAt] = 0; // NULL terminator // Convert to string assuming base ten and a positive integer. do { TempNum[--TempAt] = *NumberChar[Num % 10]; Num /= 10; } while ( Num ); // Append sign as we're going to reverse string afterwards. if ( bIsNumberNegative ) { TempNum[--TempAt] = *NumberChar[10]; } * this = TempNum TempAt; } |
这里多次用到了FString的操作符重载 =,所以让我们来看看这个操作符重载:
1 2 3 4 5 6 7 | FORCEINLINE FString& operator =( const TCHAR* Str ) { checkSlow(Str); CheckInvariants(); AppendChars(Str, FCString::Strlen(Str)); return * this ; } |
这里唯一有意义的是第6行调用AppendChars()函数-但是要注意的是另一个Strlen()调用首先完成。。。:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | FORCEINLINE void AppendChars( const TCHAR* Array, int32 Count) { check(Count >= 0); if (!Count) return ; checkSlow(Array); int32 Index = Data.Num(); // Reserve enough space - including an extra gap for a null terminator if we don't already have a string allocated Data.AddUninitialized(Count (Index ? 0 : 1)); TCHAR* EndPtr = Data.GetData() Index - (Index ? 1 : 0); // Copy characters to end of string, overwriting null terminator if we already have one CopyAssignItems(EndPtr, Array, Count); // (Re-)establish the null terminator *(EndPtr Count) = 0; } |
总之,ToString()已经证明了自己是一个相当昂贵和复杂的操作!
如果你回头看看ToString()实现的第4行,你会注意到对Empty()的调用。 这一行的意图是为最终的输出预分配好足够的内存,这样主要是希望在执行具体的工作的时候不需要重新分配内存。如果你跟踪ToString()和里面所有被调用的函数,会看到内存被分配、重新分配、几次复制和释放。
对 TOSTRING()进行优化
为了使ToString()效率最优,我意识到我需要能够简化几个操作。
作为一个老派的程序员,我倾向于通过考察我们必须输出的变量来尝试简化操作,看看它们的最终结果应该是什么。
在ToString()这个例子里面,我们真正想做的是这样的:
- 确定我们是在处理一个名字_数字类型的FName还是在处理简单的Name。
- 为输出的FName分配适当的内存。 为此,我们需要:
- 名字的长度。
- 如果有一个数字的话,则需要Number中的数字位数。
- 复制名字部分。
- 复制_Number部分(如果有的话)。
- 空白终止符。
就这么多内容了。当你把它按照这个方式整理,这真的有那么复杂么?
所以。。。我对这个内容进行了编码了。我不会进入很具体的细节。。。但实际上,我打算减少代码,以便相同的计算不重复,所以我们不会重新分配内存并移动分配好的内存。相反,我将确定我们需要的输出大小-然后直接复制元素到那里。这样的话我就结束了这里的操作。。。
在UnrealNames.cpp的顶部,我添加了一个用于启用/禁用我的新ToString()方法的定义:
1 | #define USE_NEW_TOSTRING 1 |
大部分代码放置在原始的ToString()函数之前:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | #if USE_NEW_TOSTRING int32 GetNameEntryLength( const FNameEntry* NameEntry, const bool IsWide) { uint32 NameLength = 0; if (IsWide) { const WIDECHAR* pChar = NameEntry->GetWideName(); while (*pChar ) NameLength ; } else { const ANSICHAR* pChar = NameEntry->GetAnsiName(); while (*pChar ) NameLength ; } return NameLength; } void NameToTCHARBuffer( const FNameEntry* NameEntry, const bool IsWide, TCHAR* OutChar) { if (IsWide) { const WIDECHAR* pChar = NameEntry->GetWideName(); while (WIDECHAR ThisChar = *pChar ) { *OutChar = ThisChar; } } else { const ANSICHAR* pChar = NameEntry->GetAnsiName(); while (ANSICHAR ThisChar = *pChar ) { *OutChar = ThisChar; } } } uint32 NumberToTCHARBuffer(uint32 Number, TCHAR* Buffer, const uint32 BufferLen) { uint32 NumberLength = 0; TCHAR* Out = &Buffer[BufferLen]; do { *(--Out) = '0' (Number % 10); NumberLength ; Number /= 10; } while (Number > 0); return NumberLength; } void FName::ToString(FString& Out) const { const FNameEntry* const NameEntry = GetDisplayNameEntry(); bool IsWide = NameEntry->IsWide(); // Calculate the length of the name element uint32 NameLength = GetNameEntryLength(NameEntry, IsWide); // Get the number, if there is one uint32 Number = GetNumber(); uint32 NumberLength = 0; const uint32 HasNumber = (Number != NAME_NO_NUMBER_INTERNAL) ? 1 : 0; // Calculate the length of the number element // Also, pre-fill NumberTC, from the back, with the number in string form static const uint32 NumberTC_Len = 16; // max length (10 should've been enough) TCHAR NumberTC[NumberTC_Len]; if (HasNumber) { Number = NAME_INTERNAL_TO_EXTERNAL(Number); // convert to the number we wish to output NumberLength = NumberToTCHARBuffer(Number, NumberTC, NumberTC_Len); } // Calculate the final string length (Name[_Number]\0) uint32 TotalLen = (NameLength) (HasNumber NumberLength) 1; // Prepare the memory that we're going to write to (this is "unsafe" because we're guaranteeing that we won't write off the end) Out.Empty(TotalLen); Out.GetCharArray().SetNumUninitialized(TotalLen); TCHAR* OutChar = Out.GetCharArray().GetData(); OutChar[TotalLen - 1] = 0; // NULL Terminate // Copy the string part NameToTCHARBuffer(NameEntry, IsWide, OutChar); OutChar = NameLength; // Copy the number part if (HasNumber) { *OutChar = '_' ; TCHAR* pNumberTC = &NumberTC[NumberTC_Len - NumberLength]; for (uint32 It = 0; It < NumberLength; It ) { *OutChar = *(pNumberTC ); } } } #else // USE_NEW_TOSTRING |
紧跟在原来的ToString()之后,我关闭了#if/#else/#endif。
1 | #endif // USE_NEW_TOSTRING |
这个函数的一些比较重要部分:
- 第52行是我们的起点。
- 第56行使用GetNameEntryLength()函数来获取我们的字符串的长度- 这是一个优化的很好的函数,它就是简单地计数一直到空白终止符。
- 第65-71行用Number的数据做两个事情:如果有一个数字的话,会计算存储数字所需的位数,然后使用数字以TCHAR形式来填充NumberTC,一个本地数组,是从后开始填充,使用的是函数NumberToTCHARBuffer()。
- 第74行计算我们需要的字符串的长度。。。并且在77-80行分配和初始化字符串,也会在行尾添加空白终止符。
- 83-84行使用NameToTCHARBuffer()函数复制字符串部分。
- 87-96行复制数字部分,从NumberTC中进行复制。
这真的很简单。。。
需要注意的是:在第78行,我们有SetNumUnitialized()函数。因为我们已经为上一行分配了字符串的空间,在调用Empty()函数的时候,我们可以在理论上使用SetNumUnsafeInternal()函数–这样的话速度可以更快。。。尽管这个函数内的checkSlow()看起来有点太激进-允许字符串的当前大小只能收缩,从不增长(注意Empty()设置当前大小为零)。这也许是未来可以更加深入的一点,因为我确实想知道我们是否可以改变checkSlow()来验证ArrayMax而不是验证ArrayNum。。。
为了正确测试性能,我写了一个简单的测试函数,产生了1,000,000个 FName。 看了下我们在项目中通常会得到的名称,我做了些这样的名字:50%的名字会有一个附加的数字,10%的名字将有一个数字,1%的名字将有两个数字, 0.1%的名字有三位数字等等。。。一直到有8位数字的情况(这可能不会出现在我们的100万个名字的集合之中)。然后,我创建了一个严格的循环来对名字进行迭代,在每个名字上执行ToString(),然后对整个迭代再执行10次,然后报告每次所花费的时间(使用FPlatformTime :: Seconds()和UE_LOG)。如果需要的话,我可以把这个项目留给你让你自己做测试。
ToString()转换1,000,000个名自所需的平均时间量:
- 使用原始的ToString(): 0.286秒。
- 使用新版本的ToString(): 0.118秒。
我们新版本的ToString()比旧版本的ToString()快了2.4倍!
这不错,考虑到原来“只有”6行代码。
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。