写在前面
为什么我要写UE4的GC?
网络上已有不少关于UE4垃圾收集分析的优秀文章,我为什么还要多写一篇呢?
其一是 我希望这篇文章既能从宏观上成体系的理解UE4垃圾收集的全貌而不涉及算法实现细节,又能纵向的和各类经典垃圾收集算法做一个横向对比,阐述UE4垃圾收集在技术选型上所采用的是何种策略,同时分享一下项目开发过程中碰到的GC时耗分布状况和对应的调优手段,希望对开发者调优垃圾收集的实际的参考意义。
其二是最近这段时间我在着手UE4垃圾收集的优化,但因为个人能力有限及工作重心和多个游戏版本并行更迭原因而断断续续的花式碰南墙,期待能抛砖引玉,或指出分析过程中的错误或提出其中更有效的优化手段。
一、基础优化篇
C++引入垃圾收集,其目标是降低开发者的准入门槛——开发人员大绝大多数时候无须关心对象的生命周期问题和对象所持有资源的内存问题,这就使得开发人员不用关心内存分配和释放配对,不需要仔细设计对象的释放流程,在大绝大多数时候也无须担心所持对象在别的什么地方被人释放而变成野指针,从而导致一些非常难定位的崩溃和错误,而这些问题长期以来也一直是CC++被人广为诟病的缺点。但同样,对于对象生命周期管理和内存管理的精巧设计,同样也是CC++高效魔力最有价值的部分之一,而垃圾收集把这把双刃剑磨成了板砖。
UE4 GC执行流程(增量式GC)
UE4的垃圾收集算法使用的是优化过的标记--清除GC法,具体触发的GC的入口在UEngine.ConditionalCollectGarbage函数中,真正的GC流程实现是CollectGarbageInternal中,UE4垃圾收集的执行过程如下图所示:
图中的高消耗部分标为了红色,以下是关于这个图的细节说明:
- 图中"Destroy Objects" 和 "压缩ObjectHash表"部分是单线程同步执行的。
- "Verify GC & Cluster"在Shipping包中不会执行。
- "Mark"子流程是多线程并行执行的,同时GameThread会等待这个子流程执行完毕,UE4的Mark实际上分两步,第一步是找出根节点和Cluster节点做为Mark的可达集,第二步则是通过深度优先遍历可达集来访问整个UOjbectGraph(注:此UObjectGraph是DG而不是DAG,因为对象有循环引用),Mark具体的实现在PerformReachabilityAnalysis函数中
- "收集Unreachable objects"是多线程并行执行的,GameThread不需要等待它执行完毕
- "不可达对象 BeginDestroy调用" 只有在全量GC时、或者本次GC开始的时候上一次增量GC还未完成时会同步执行
- DestroyObjects实际上还包含FMemory::Trim通知内存池进行真正的内存回收,把内存归还给系统
GC流程基础优化
- 为 "Verify GC & Cluster" 加上控制台变量,通过配置来决定是否开启Verify功能,这在使用Development配置进行游戏测试时能很大程度上降低GC带来的游戏卡顿
- "压缩Object Array中的Object Hash表"和整个GC流程无关,可以提到外面单独的执行流程中,事实上对于大多数单局制游戏,在每局游戏进出时进行Object Hash压缩就已足够
- “FMemoryTrim调用”和GC流程无关,可以提到外面单独的执行流程中,事实上在非强制GC或非内存紧张的时候,总是可以挑一个CPU使用率较低的时间点进行内存集中回收
- "解组Cluster" 和GC流程无关,它只要在下次GC执行之前完成就可以了,事实上解组Cluster任务稍作修改,可以丢到异步任务队列中执行
- 为项目开启GC Cluster生成 ,为项目开启Actor和蓝图的Cluster生成,虽然ActorCluster在UE4中不会带来本质上GC效率的改观,但是在蓝图众多的项目上,Cluster在4.25以下的版本中会有一定的优化效果。Cluster开启的方法可以是在项目属性中勾选:
或是直接在配置文件中进行开启:
gc.CreateGCClusters = 1
gc.ActorClusteringEnabled = 1
gc.BlueprintClusteringEnabled = 1
- 打包游戏到手机上运行,从Log中获取推荐的gc.MaxObjectsNotConsideredByGC和gc.SizeOfPermanentObjectPool值并填入到DefaultEngine.ini文件中或项目属性中。
从使用者的角度来看,这两个值意义为:
gc.MaxObjectsNotConsideredByGC对应的是Object在全局ObjectArray中的索引,小于该值的所有UObject不会参与GC的标记
gc.SizeOfPermanentObjectPool对应的是GUObjectAllocator分配器中的内存块,属于gc.SizeOfPermanentObjectPool以内的内存块会一直被UE持有而不会归给系统
比较有趣的是,在有了DisgardObjects机制之后,gc.MaxObjectsNotConsideredByGC基本上对GC性能变得没有影响,两者都用于把UE4引擎类中的UProperty反射信息从GC的标记阶段剔除。
GC触发时机
UE4在以下主要情况下会触发全量GC:
- PersisitentLevel切换时
- LevelStreaming在StreamingOut某个关卡时
- 对于DS运行的情况:当开启ReplicatedGraph且ReplicatedGraph中的Grid销毁数量达到配置的阈值时
- 蓝图里调用GarbageCollection时(UKismetSystemLibrary.GarbageCollection)