那些玩过UE4的朋友,应该都清楚,它的垃圾回收也就是GC,虽说稳定,然而在处理大型且复杂的项目之际,时不时就会带来一帧能被肉眼察觉到的卡顿。UE5针对这个核心问题做了不少改动,尽管底层的标记 - 清扫算法没有改变,不过借助各种“巧妙操作”,将GC的效率提升了一个等级,甚至还引入了具有实验性质的增量GC,尝试彻底解决卡顿这个顽固的问题。
enum class EInternalObjectFlags : int32
{
None = 0,
//...
RefCounted UE_DEPRECATED(5.7, "Use GetRefCount() to determine if a refcount exists instead.") = 1 << 29, ///< Object currently has ref-counts associated with it.
//...
};
引用计数的小试牛刀
UE5的GC最为直观的变化,是给UObject添加了一个名为TF_KeepAsReferenced的Flag,这个Flag的出现,意味着UE在传统的标记 - 清扫的基础之上,开始去借鉴引用计数的思路,简单来讲,就是每个对象都记录自身被引用的次数,然而引擎并没有使用int32去完整地记录这个计数,而是将多余的位数用作存储一些自定义或者引擎内部的标志位。
void UObjectBase::AddRef() const
{
FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this);
ObjectItem->AddRef();
}
void UObjectBase::ReleaseRef() const
{
FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this);
ObjectItem->ReleaseRef();
}
好处方面,这般去做的益处是极为显著的。判断垃圾明显更快了,无需再进行全场扫描。坏处层面,然而那样却有着致命的弊端。一旦在引用计数管理上出现差错,像是缺少了解除引用的操作,那么对象就势必永远不会被回收清除。致使结果,鉴于这样子的情况,UE5相当克制,只需在极为特定的地方,例如处理某些特殊引用时,才会启用这一机制,而主力保持不变的仍然是稳重可靠的标记 - 清扫。
FORCEINLINE int32 GetRefCount() const
{
return RefCount;
}
解决环形引用的难题
FORCEINLINE_DEBUGGABLE void Reset(ObjectType* InNewObject)
{
if (InNewObject)
{
if (Object == InNewObject)
{
return;
}
if (Object)
{
// UObject type is forward declared, ReleaseRef() is not known.
// So move the implementation to the cpp file instead.
UEStrongObjectPtr_Private::ReleaseUObject(Object);
}
InNewObject->AddRef();
Object = InNewObject;
}
else
{
Reset();
}
}
存在着这样一个情况,引用计数存在着与生俱来的短板,那便是处理不了环形引用,举例来说,A引用了B这项构成引用,而B引用在了C上,C又对A进行了引用,如此便形成了一个环状引用关系网,在这种状况下,它们三个均不存在外部引用了,然而各自呈现的引用计数却都并非是0,要是运用纯引用计数法的话,它们永远都不会被回收处理,UE5所给出的例子极为典型,存在四个对象彼此之间建立了引用以至于出现这环状引用情况,从逻辑层面去看待,它们早就应该要被判定之为垃圾项了。
因为是这样的缘故,所以说一个已然成熟的垃圾回收方案绝对不可以仅仅只是使用引用计数来达成。虚幻引擎5所采用的方式是极为巧妙的,其 将引用计数当作是标记 -清扫这种方式的辅助手段来运用。标记 -清扫是从根集开始出发的,能够遍历到的对象便就是存活的,而遍历不到的对象则就是死亡的,那些存在环形引用的对象由于不存在根引用,自然而然地就被划分进入到死亡名单之中,如此一来,这个难题便被完美地解决掉了。
内存预取的提速黑科技
在UE4那个时代,当去处理对象引用之时,会需要频繁地去访问那个存储引用的大数组。比如说要是想要添加一个引用,那就得对全数组进行遍历,以此来检查是不是已经存在了;而当要进行删除的时候,又得再遍历一遍去寻找位置。等到数组里面的元素数量增多起来以后,这种遍历所产生的时间开销是非常可观的,并且对于CPU缓存而言是很不友好的。
FORCEINLINE_DEBUGGABLE void ProcessObjects(DispatcherType& Dispatcher, TConstArrayView CurrentObjects)
{
for (FPrefetchingObjectIterator It(CurrentObjects); It.HasMore(); It.Advance())
{
UObject* CurrentObject = It.GetCurrentObject();
UClass* Class = CurrentObject->GetClass();
UObject* Outer = CurrentObject->GetOuter();
//...
}
}
现在引入了UE5的内存预取技术,鉴于知晓随后会频繁访问这些对象的引用关联信息,故而会基于操作系统接口提前将这些数据以异步方式加载至CPU缓存里头,这样在此之后CPU执行指令之际,数据已然在缓存等待着指令,而无需再耗费几百个周期前往主内存获取数据,自然执行效率会显著提升。
函数拆分与批量处理
可达性分析,其本质上是一种广度优先搜索,它是从根集开始出发,然后一层层地去遍历所有引用。UE4是一次性就把所有可达对象都全面找出来,如此一来容易造成一帧卡顿现象。UE5则是将整个分析流程拆分成了多个小的函数,其一,每个函数的逻辑都极其精简,最高限度是不超过90行,其二,这对于CPU的指令缓存而言是特别友好的。
于具体执行期,UE5运用了Batch方式,大约每500个对象被当作一个批次,逐个次序流经那些精简过的函数,因处理的数据量减小,故而对内存的需求变低,致使CPU Cache能够轻易装下,随着函数逻辑趋于简单,内存预取亦更为便利,整个标记流程变得既及时又稳定。
引用收集器的优化
UE5引入了“CollectReferences”这个概念,它可不是别的,实际上是对UE4里“FGCReferenceInfo”的一种升级,它所记录的内容是这样的,那就是某个对象当中带有引用的成员地址偏移,而且还包括其类型。主流程会先去收集一些常见的引用类型,像是那个 Class 以及 Outer,由于每个对象基本上都存在,把它们提取出来进行统一处理的话,能够极大地减小 FGCObject 的大小。
接下来是名为ProcessExtendedReferences的函数,它是专门用作处理对象里所定义的各类复杂引用的,像单个UObject引用、数组引用之类的。在此处存在这样一个优化细节,也就是在处理的进程当中并不会马上就对这些引用进行处理,而是会先留存到容器里面,一直到后面来临的恰当的时候再去“Flush”进行统一处理,如此便减少了中间状态的维持所需要花费的开销。
强制删除与引用调试
在项目开展的进程里,偶尔会萌生强制去除某个对象的想法,比如说对象B属于A的一项属性 , 然而A针对B的引用力度颇为强劲,要是径直调用"MarkPendingKill"去尝试强行删除,垃圾回收器就会察觉到B依旧被A所持援引,基于这种状况,便会给B添加"TF_KeepAsReferenced"这一标识,以此来阻拦此次删除行为,进而将其持续留存于对象数组当中。
UE5增添了一项具备实用性的Debug功能,该功能能够记录上次GC进程里每个对象被哪一方引用了。在遭遇到怀疑存在内存泄露的情况时,能够从GC快照之中获取到历史引用信息,从而能够迅速定位到泄露现象所处的源头。举例来说,要是某个对象毫无缘由未被加以回收,只要查看这份记录就能知晓是哪一个“钉子户”依旧在对其进行引用。
增量GC的实验性突破
首先,最大的不同之处在于增量式可达性分析。然后,试想,如果进行那次分析需要花费三帧才能完成 ,在第一帧的时候对对象A展开了分析 ,到了第二帧执行了A.XX = B从而让A引用B ,然而A在此之前已经被分析过了 ,这样一来系统就无法识别这个新的引用 ,进而有可能错误地将B进行回收。最后,UE5借助把裸指针替换为TWeakObjectPtr这类智能指针 ,能够在赋值操作期间捕获变化。
在具体实现之时,PerformReachabilityAnalysis函数运用了Batch遍历方式,每一趟处理500个对象。借助时间分片逻辑,把控每帧所耗费的时间,一旦超出预算便暂停,待下一帧再持续进行。每一个线程均拥有自身的TArray用以保存进度,不但标记阶段支持增量操作,而且紧随其后的收集不可达对象阶段也达成了增量执行效果,从而使得游戏运行之际更为平滑顺畅。
项目里,你当下有没有碰到过因GC致使的卡顿情况,又或者是拥有自己的一套优化体会呢?欢迎于评论区去分享你的实战经历,要是觉着内容有价值的话可别忘记点赞并转发呀。
FORCEINLINE_DEBUGGABLE void QueueStructArray(FSchemaView Schema, uint8* Data, int32 Num)
{
StructBatcher.PushStructArray(Schema, Data, Num);
}



还没有评论,来说两句吧...