Unity游戏在华为设备升级鸿蒙系统后的掉帧问题
问题背景
游戏在“荣耀9x”上原本帧率正常,但手机升级到鸿蒙系统后,出现了严重的掉帧现象。后台应用(网易云音乐、微信、QQ)会加重掉帧现象,开启手机的性能模式也没用。
前置知识点
- big.LITTLE:一种多核cpu架构,由大核和小核组成。大核注重性能,小核注重能耗。目前移动设备的cpu普遍使用这种架构。
- Thread Affinity:控制线程可以运行在哪些核上,俗称“绑核”。对Unity安卓平台的线程来说,有3类:大核(线程只能在大核上执行)、小核(线程只能在小核上执行)、任意核(线程可以在所有核上执行)。
- Worker Thread:Unity用于多线程执行某些任务,比如场景裁剪、UI合批、粒子系统更新、CPU Skinning、动画计算等。可以在Unity内置的Profiler中查看哪些任务运行在该类线程上。
查证分析
华为的反馈
通过华为开发者网站反馈掉帧问题后,收到华为的分析报告,主要内容如下:
- 工作线程(Worker Thread)在游戏启动时,绑到了大核
- 从systrace收集的数据看,掉帧的时候,游戏的主线程、渲染线程和6个工作线程都在大核上执行
- 从simplerperf收集的数据看,掉帧的时候,渲染线程的CPU占用远高于不掉帧的时候,并且通过反编译看到渲染线程和工作线程在竞争写一个地址
- 基于以上信息,华为开发人员认为是主线程、渲染线程和6个工作线程在竞争2个大核,导致了掉帧,给出的建议是不要将工作线程绑到大核
分析Unity 2017构建的版本
查看游戏使用的Unity2017.4.40的源码,发现在安卓平台上,Unity通过以下方式绑核
int processor; // ... syscall(__NR_sched_setaffinity, tid, sizeof(processor), &processor);
这个系统调用的参数可以参考Linux的sched_setaffinity函数,它的最后一个参数是mask,每一位对应一个核,为1表示线程可以执行在对应的核上,默认情况下,线程的affinity是-1,表示可以执行在任意的核上。
Unity在启动时,会按照一套自己的逻辑,确定哪些核是大核,哪些核是小核,并记录相应的mask。比如,对于“荣耀9x”(cpu是2个大核加6个小核),大核的mask是0xc0(CPU 6-7),小核的mask是3f(CPU 0-5)。
这个逻辑在Unity2018及以前的版本不完善,对一些有大、中、小核的架构,可能把中核识别为小核,导致主要的线程都绑在了少数的大核上,比如麒麟9000,它是1大3中4小,Unity会识别为1大7小。最近的Unity版本已经修复了这个问题,具体修复的版本可以看这里。
下面是Unity为各类线程设置的mask(安卓平台,且cpu是big.LITTLE架构):
| processor(mask) | |
|---|---|
| 主线程 | 大核 |
| 渲染线程 | 大核 |
| 工作线程 | 0 |
可以看到比较奇怪的一点是Unity为工作线程设置了0的mask,并且代码注释写着期望能够运行在所有核上,但根据华为的反馈,工作线程是绑到了大核。查看sched_setaffinity函数的文档,发现mask传0时,函数应该不工作,并返回错误码EINVAL。
本地尝试使用systrace收集游戏在“荣耀9x”运行出现掉帧时的数据:

图中土黄色部分表示在这段时间内是游戏的进程占用了某个cpu,可以看到游戏主要执行在2个大核(Cpu 6和Cpu 7)。
尝试自己将工作线程设置为不绑核(方法在后面),发现确实不会掉帧了。此时,systrace收集的数据如下:

可以看到,手动设置工作线程不绑核之后,工作线程确实运行在了不同的核上。
基于同样的包体在安卓没有出现掉帧的情况和Unity源码中的注释,此时怀疑对于mask传0这样特殊的值,鸿蒙和安卓的行为不一致,导致了不该绑到大核的工作线程实际绑到了大核。但经过验证(模仿Unity,调用syscall时mask传0),发现无论是在安卓,还是在鸿蒙系统上,mask传0时的返回值都是22(EINVAL),符合文档的描述,因此推测这个系统调用在两个系统上应该都是不起效的,基本否定了前面对于安卓和鸿蒙行为不一致的猜测。
那为什么工作线程会绑到大核呢?google后,发现线程的绑核设置(Thread Affinity)是会被继承的。以下描述来自pthread_create函数的文档
The new thread inherits copies of the calling thread's capability sets (see capabilities(7)) and CPU affinity mask (see sched_setaffinity(2)).
Unity在安卓(cpu是big.LITTLE架构)上,是先把主线程绑到大核,再进行初始化,初始化的过程中使用pthread_create创建了其他线程,包括工作线程,所以工作线程继承了这个属性,绑到了大核。后续在对工作线程调用syscall绑核时,由于mask传0,调用失败,所以没有影响到它绑大核的设置。
这样看,无论是在安卓,还是鸿蒙系统上,工作线程应该都是绑到了大核,所以推测工作线程绑大核的行为本身不是掉帧的根本原因。
分析Unity 2020构建的版本
在收到华为反馈后,我们还测试了另一个使用Unity2020.3.24构建的版本,发现并没有掉帧现象。通过查看Unity源码和分析systrace收集的数据,发现它的工作线程虽然也是绑了大核,但数量却只有2个。
下面是Unity各版本的工作线程的绑核情况和数量(安卓平台,且cpu是big.LITTLE架构)
| 2017.4.40 | 2018.4.28 | 2019.4.0 | 2020.3.24 | |
|---|---|---|---|---|
| 绑核情况 | 0(实际由于继承,绑到了大核) | 大核 | 大核 | 大核 |
| 数量 | 小核数 | 小核数 | 大核数 | 大核数 |
可以看到,Unity 2017和2018的工作线程数量其实是有问题的,线程绑到了大核,数量却是小核数量。在大核数比较少的设备上,工作线程的数量会超出大核数量,比如“荣耀9x”(cpu是2个大核加6个小核,所以创建了6个工作线程)。
由此,可以解释为什么Unity 2020构建的版本不会掉帧,而Unity 2017构建的版本会掉帧:Unity 2017创建的工作线程数量过多,加剧了线程抢占的现象。
其他分析
在继续研究该问题的时候,偶然发现有方法可以查看线程的绑核情况(其实在查该问题的初期,有尝试过该方法,但当时刚好是在一台cpu不是big.LITTLE架构的手机上测试,发现各个线程都没有绑核,误以为是该方法取不到实际的绑核数据。后来发现是Unity在这样的设备上,本来就不绑核):
# 进入设备的shell(确保adb devices列出了设备) adb shell # 显示按cpu使用率排序的进程信息,游戏一般在第一个,记住它的pid top # 查看游戏所有线程的信息,要看的是线程id(tid) top -H -p [pid] # 查看线程的绑核情况 cat /proc/[pid]/task/[tid]/status
上面命令输出的结果:

通过该方法发现一些奇怪的现象:
- 渲染线程(UnityGfxDeviceW)在鸿蒙系统上,没有绑核,而在另外一台安卓10的设备(cpu是四大核+四小核)上,是绑到了大核。按照Unity的实现,绑到大核才是期望的结果。
- 在安卓系统的华为P40设备上,主线程绑到了中核上(CPU5~6),但在鸿蒙系统的P40上,主线程绑到了大、中核上(CPU5~7)。按照Unity的实现,绑到大核(CPU6~7)才是期望的结果。
从这些现象推测,安卓/鸿蒙系统会自己对app设置的绑核做调整。
修复方案
虽然上面分析了不少源码的逻辑和工具的数据,但是依然没有找到“手机升级到鸿蒙系统后,游戏掉帧变严重”的原因。不过,这个问题还是有解决方案的:
方案1:升级到Unity 2019或以上的版本。工作线程数量正确后,就不会有严重的抢占了
方案2:如果不想升级Unity版本,可以在有问题的设备上,动态将工作线程设置为不绑核。可使用这里的代码
其他
如何查看安卓设备的cpu是否是big.LITTLE架构,有几个大核、几个小核?
- 可以使用adb logcat -s Unity,查看Unity游戏启动时输出的日志,比如
12-31 14:37:11.374 5986 6043 I Unity : SystemInfo CPU = ARM64 FP ASIMD AES, Cores = 8, Memory = 2826mb 12-31 14:37:11.374 5986 6043 I Unity : SystemInfo ARM big.LITTLE configuration: 8 big (mask: 255), 0 little (mask: 0) 12-31 14:37:11.375 5986 6043 I Unity : ApplicationInfo com.xxx.yyy version 0.69281 build 2d598bac-f1b5-40c6-b541-b7e5779e32b7 12-31 14:37:11.375 5986 6043 I Unity : Built from '' branch, Version '2017.4.40f1 (0)', Build type 'Release', Scripting Backend 'il2cpp'
查看线程绑核情况的其他方式
- taskset -p [pid]
- 由于权限不足获取失败,可能需要root
- syscall(__NR_sched_getaffinity, tid, sizeof(processor), &processor)
- 注意sizeof(processor)至少需要是128,否则会调用失败
- taskset -p [pid]
关于 top -H -p [pid]
- 该命令在某些设备(比如“红米6 Pro”)上只能看到一个线程的信息,而在其他设备(比如“荣耀9x”和“黑鲨helo2”)可以看到所有线程的信息
其他游戏在鸿蒙系统上是否有类似的掉帧问题?
- 虽然没有直接的证据证实,但通过上面的方法查看《王者荣耀》(基于Unity 5.6)和《原神》(基于 Unity 2017.4.30)的线程,发现在“荣耀9x”上,它们分别创建了2个和3个工作线程,所以推测这两款游戏都没有这种情况
链接
- Unity Android Configuration:https://docs.unity3d.com/2021.2/Documentation/Manual/android-thread-configuration.html
- systrace:https://developer.android.com/topic/performance/tracing/command-line
- sched_setaffinity:https://man7.org/linux/man-pages/man2/sched_setaffinity.2.html
- 麒麟芯片的信息:https://developer.huawei.com/consumer/cn/forum/topic/0201676881402080409?fid=0103325401414330531(其中分析Unity绑核策略的部分已经过时了)