虚幻引擎4:到底是使用ToString() 还是不使用ToString() ...等等,这个问题是什

发表于2017-07-21
评论1 1.7k浏览

译者: 崔国军(飞扬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行代码。


【版权声明】

原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

标签: