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,否则会调用失败
  • 关于 top -H -p [pid]

    • 该命令在某些设备(比如“红米6 Pro”)上只能看到一个线程的信息,而在其他设备(比如“荣耀9x”和“黑鲨helo2”)可以看到所有线程的信息
  • 其他游戏在鸿蒙系统上是否有类似的掉帧问题?

    • 虽然没有直接的证据证实,但通过上面的方法查看《王者荣耀》(基于Unity 5.6)和《原神》(基于 Unity 2017.4.30)的线程,发现在“荣耀9x”上,它们分别创建了2个和3个工作线程,所以推测这两款游戏都没有这种情况

链接