4.21.2015

为性能表现最优化TArray的使用

作者 Zak Middleton

在虚幻引擎中,TArray是输入元素的动态大小的数组。TArray对程序员来说使用非常方便,而且在代码库中使用频率*很高*。但是可能会出现一些轻微的性能表现问题,为了最佳的性能表现,您需要确认您了解后台正在执行的操作。

做出明智选择: TArray适合您吗?

和以前一样,当我们谈到性能时,您应该分析一下代码,以确认您的处理正确。如果预先就能操作正确,就可以避免一开始的可能问题。

对于容器类型(比如TArray、TSet、TMap等等),如果您在关键行需要对代码进行操作,很多情况下,您需要考虑的最重要的事是使用正确的容器。举例来说,如果您需要保持一列特有元素并需要经常添加、移除或搜索它们,那么您可以使用TArray这个容器,但实际上TSet是一个更好的选择。

如果您想要在紧密的存储空间中非常快地进行元素迭代,那么您可以选择TArray。但是,您需要在写代码时查看一下其他操作(添加、移除等等)的含义。这里我们有一些简单方法,可以帮助您确认对TArray的使用最优化,从而不必在发布游戏时占用宝贵的优化时间!

1. 根据配置策略,默认情况下TArray会随着项目的添加而增多并重新排列存储空间。

添加元素到TArray很简单,这也是TArray如此方便的原因之一。在底层,随着更多项目的添加,更多的存储空间会定期排列,并且每次这样操作时,都必须复制元素到新空间,并随后释放旧有存储空间。这样做可能会消耗大量系统性能,我们希望能尽可能防止这种情况。我们可以进行如下操作:

1.1: 如果您知道需要添加多少项目,或者您大致了解上限,那么您可以事先预留空间。

举例来说,如果添加一行代码来调用"Reserve",则可以确保您在这个函数内最多只进行一次配置:

// Adds N copies of a character to the end of an array.
void AppendCharacterCopies(char CharToCopy, int32 N, TArray<char>& Result)
{
	if (N > 0)
	{
		Result.Reserve(Result.Num() + N);
		for (int32 Index=0; Index < N; Index++)
		{
			Result.Add(CharToCopy);
		}
	}
}

一般来说,随着数组定期扩展,进行N次的元素逐项添加可以会导致一些数组再分配和复制的问题,但在这里,通过在开始添加额外元素之前对当前元素数字的取入,并确认运算结果可以保存至少N个额外的元素,我们可以确保最多只进行一次分配。如果已经存在足够的空间,则根本不需进行分配!

1.2: 当把TArrays作为函数参数使用时,要引用它的值。

在1.1的示例中,请注意函数进行了声明,从而对TArray进行了引用。可以采用一个替代的声明:

// takes array by value!
void AppendCharacterCopies(char Original, int32 N, TArray<char> Result)

通过传递数值,函数调用程序会在将其传递到AppendCharacterCopies()之前首先制作数组的拷贝。这样非常占用系统性能,因为它会分配内存并复制所有元素到新TArray中。而且它还会中断函数的功能,这会影响调用程序对数组的拷贝!

看起来很基础,但如果忘记输入"&"可能就会导致严重的循环问题。

2.根据配置策略,默认情况下TArray会随着项目的删减而收缩并重新排列存储空间。

TArray把元素存储在紧密的、线性数组中。从任意位置(除列表末端以外)移除元素将会导致该位置后的所有元素进行移动,从而填补空白。另外,TArray认为您移除元素是为了获得存储空间,所以在某些情况下,随着元素的移除,存储空间会进行重新分配,以降低对存储空间的使用。这会要求您复制元素到新存储空间并清理旧有空间。

幸运的是,我们有一些选项可以减少这个问题:

2.1: 在移除元素时减少拷贝。

一种方法:

// initial array:
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9

// remove element at index 3
Array.RemoveAt(3);

// temporary state as element is being removed
// 0, 1, 2, _, 4, 5, 6, 7, 8, 9
// elements 4,5,6,7,8,9 all shift left
// 0, 1, 2, 4, 5, 6, 7, 8, 9

当某个元素被删除时,有一种方法可以防止移动所有元素来填入空白时对系统性能所造成的影响:RemoveAtSwap()。它会按照所需索引来移除元素,然后在可能的情况下会将其替换为数组中的最后一个元素,从而填入空白区域,而不是移动所有元素。

更快的方法:

// initial array:
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9

// (remove element at index 3, allowing it to swap in the last element to replace it)
Array.RemoveAtSwap(3);

// temporary state as element is being removed
// 0, 1, 2, _, 4, 5, 6, 7, 8, 9
// element 9 swaps position to where 3 previously was
// 0, 1, 2, 9, 4, 5, 6, 7, 8

只需一个简单的变更,我们就可以再次改善性能表现。使用这种方法的前提是,您不在乎在进行删除操作后的元素顺序。

2.2: 控制内存清理。

大多数TArray元素的删除函数会取入"bAllowShrinking"参数。这样就等于控制了在元素被删除时,TArray是否能清理空间。有时这是一个好方法,因为这样您就不用把内存空间浪费在持续存在很长时间的数组中了,但对于短暂存在的数组来说,这样做会浪费宝贵的时间,而且会污染缓存。

让我们看一下TArray::RemoveAtSwap:

void RemoveAtSwap(int32 Index, int32 Count = 1, bool bAllowShrinking = true)

在这个示例中,我们删除满足特定标准的所有元素(值为均等),并告知数组在项目被删除时不要变更存储空间的分配。

void RemoveEvenValues(TArray<int32>& Numbers)
{
	for (int32 Index = 0; Index < Numbers.Num(); Index++)
	{
		if (Numbers[Index] % 2 == 0)
		{
			Numbers.RemoveAtSwap(Index, 1, false);
			Index--;   // since we need to process this index again!
		}
	}
	
	// Optional: reclaim empty space after values have been removed
	Numbers.Shrink();
}

TArray已经具有一些内置函数来进行类似的操作: RemoveAll、RemoveAllSwap、RemoveSingle、RemoveSingleSwap等等都是很有用的工具,可以用来从数组中删除特定的元素,而且它们已经具有高效的应用。

3. TArray的默认分配器是动态的(堆)内存分配器。

有时这样做是好的,而且正符合您的期望(特别是数组会变成特别大的情况),这也是TArray如此方便的原因之一。但对于heap allocation(堆分配): locks,也会产生一些系统性能消耗。由于您现在会访问更多的地址空间,所以会产生搜寻空闲内存块和缓存污染的损耗。

但是,有一些选项在某些情况下可以很方便地就改善性能表现。这些选项就是使用默认分配器以外的分配器。一般情况下这个很难做到,但在虚幻引擎4中,这就很容易了。

我要谈到的一个很有用的分配器是TInlineAllocator。这是一个对取入两个模板参数的容器(比如TArray)特别设计的分配器:

- 内联元素的数量

- 次要分配器 (默认是堆分配器).

首个参数(内联元素的数量)是我们最为感兴趣的-- 这个分配器保留了分配器被实例化处的存储空间。当这个空间被用尽时,分配器会移动元素到由第二分配器创建的溢出空间中。对于TArray来说,这意味着在栈上声明的数组将会对这些元素直接在栈上保留空间。作为类或结构体的一部分声明的TArray将会把内联分配存储为该类或结构体的一部分-- 对作为该数组一部分添加的前N个元素将不会进行动态分配。

这样有多个好处。如果您非常了解在通常情况下会出现在您的数组中的最大数量元素,您可以完全防止出现任何堆分配,因为它们都会出现在作为TArray的一部分而创建的空间中。另外,随着元素的添加,您不需要定期增加数组并复制元素到新近分配的空间中。

这表示我们可以像这样来取入普通的TArray值:

TArray<Shape*> MyShapeArray;

然后很方便地将其更改为:

TArray<Shape*, TInlineAllocator<16>> MyShapeArray;

在这个示例中,您不用对前16个添加到数组的元素进行任何动态分配,因为它们正好能填入作为TInlineAllocator一部分的栈的区域。在17个元素之后,所有元素都被移动到第二分配器(比如堆分配器)以供存储。

TInlineAllocator的大小必须在编译时进行确认。您可以自行考虑是采用可能的快速方法,还是保留这些空间以供您数组的*每个*实例使用。我们还要提一下TFixedAllocator,它类似于TInlineAllocator,但是区别是没有第二备份分配器;如果在固定存储部分没有空间了,那么您的代码会产生错误。

4. 让您的类型更易使用

一般来说,声明您的数组类型(这样也可以节省您输入的时间!)的更好方法是使用typedef。这样做的好处是,您可以很方便地来更新一处的任意内联元素的硬编码数字,并将其传送到所有实例中。实例:

// declare type
typedef TArray<Shape*, TInlineAllocator<16>> ShapeArrayType;

// create an instance
ShapeArrayType MyShapeArray;

在迭代任意类型的TArray时,我们可以很方便地使用typedef,从而防止输入超出TArray类型定义的内容,这也会让之后变更类型变得更为方便。:

for (ShapeArrayType::TIterator Iter(MyShapeArray); Iter; ++Iter)
{
	Shape* MyShape = *Iter;
	MyShape->Draw();
}

您也可以使用 C++11 "自动"关键字并将其替换首行内容:

for (auto Iter = MyShapeArray.CreateIterator(); Iter; ++Iter)

一个很简洁的方法是使用基于范围的内容作为循环(请参考这篇日志以获得更多信息)。在使用"自动"关键字时,一般情况下,您应该将元素类型捕获为指针或引用以防止不需要的拷贝:

for (auto* MyShape : MyShapeArray)
{
	MyShape->Draw();
}

5. TArray类型转换

具有不同分配器的TArrays(或任何容器)实际上是不同的类型,因此它们无法被自动转换为另一种TArray类型。举例来说,您无法把来自上方的MyShapeArray传递到取入TArray<Shape*>的函数中。

int32 GetNumShapes(TArray<Shape*>& ShapeArray)
{
	return ShapeArray.Num();
}

void TestCompile()
{
	TArray<Shape*> HeapShapeArray;
	TArray<Shape*, TInlineAllocator<16>> InlineShapeArray;
	
	FillShapeArray(HeapShapeArray);
	FillShapeArray(InlineShapeArray);
	
	// ok, type is TArray<Shape*, FDefaultAllocator>&
	const int32 A = GetNumShapes(HeapShapeArray);
	
	// *error*: cannot convert from TArray<Shape*, TInlineAllocator<16>>&
	// to TArray<Shape*, FDefaultAllocator>&
	const int32 B = GetNumShapes(InlineShapeArray);
}

但通过使用一些简单的模板代码,您可以让它正常运行:

template<typename AllocatorType>
int32 GetNumShapes(TArray<Shape*, AllocatorType>& ShapeArray)
{
	return ShapeArray.Num();
}

void TestCompile()
{
	TArray<Shape*> HeapShapeArray;
	TArray<Shape*, TInlineAllocator<16>> InlineShapeArray;
	
	FillShapeArray(HeapShapeArray);
	FillShapeArray(InlineShapeArray);
	
	// ok, compiler infers template type of allocator as FDefaultAllocator
	const int32 A = GetNumShapes(HeapShapeArray);
	
	// ok, compiler infers template type of allocator as TInlineAllocator<16>
	const int32 B = GetNumShapes(InlineShapeArray);
	
	// ok, explicit with template type, but not really necessary
	// or very reusable with hard-coded "16"
	const int32 C = GetNumShapes<TInlineAllocator<16>>(InlineShapeArray);     
}

就是这样!进行了简单添加后,我们使得它更为灵活,而且可以和任意分配器类型的TArray一起运行。

最后的注意点:在添加模板参数到大型函数时,要注意代码膨胀,因为编译器会为每个使用的模板类型生成一个代码版本。另一个方法是在您的代码中直接使用持续的typedef,这样还是只能使用单一类型,但变更起来要更为方便。

最近文章

Unreal Studio 4.20测试版现已推出!

什么工具能比Unreal Studio更好用?当然是Unreal Studio 4.20版啦!元数据导入,更流畅的导出流程以及在虚幻引擎中编辑网格体等...

Holospark的《地球沦陷(Earthfall)》为合作射击类游戏带来创新

总部位于西雅图的独立开发商Holospark在它的四人合作射击游戏 《地球沦陷》中展现了美国太平洋西北地区的风貌。

虚幻引擎帮助The Mill和Monster.com驱动怪物傀儡

屡获殊荣的The Mill工作室需要制作一些以巨型毛绒生物为主题的动画,而且要快。通过巧妙的技术以及虚幻引擎的帮助,他们的成果达到并且超出了Monst...