最近富途的开发者在微信上联系我,说他们debuggable版本的app在Android 13上没有问题,结果到14上就特别卡顿。他们认为这是一个虚拟机的问题,于是向谷歌上报,可是几个月过去也没得到反馈,问我能不能帮忙。我当时心想:不会又是app自身行为导致然后甩锅给系统的问题吧。不过点开链接后我便相信了他的判断,而且问题上的+1
按钮被点击了28次,表明这个问题确实影响不小。
一位不知名的网友(某大厂的工程师?)在页面上留言到:
I found that the execution mode of the debuggable interpretation on Android 14 has changed. In the bootImage method, Android 13 uses nterp and Android 14 uses switch. This can cause the app to run very slowly.
翻译:我发现debuggable环境下,解释器的执行模式在Android 14上发生了改变。在Boot image方法中,Android 13使用nterp的解释器,而Android 14使用的却是switch解释器。这会导致app运行的非常慢。
这条留言给了我很大的启发,于是顺着他的思路我去查看了源码和修改记录,发现确实如此。问题由下面这笔改动引入:
知道问题的出处固然重要,但更重要的是思考改动的原因和修复的方案。于是我跟Google的工程师做了些沟通,下面就是这些沟通内容的梳理和展开。
1. 原始改动的原因
首先是为什么要做这笔改动?从这笔改动的commit message可知,nterp为了支持调试需要intrumentation stub(entry/exit hooks)。而instrumentation stub由于设计的复杂性,在实际使用中有些问题,因此谷歌决定废弃它。以下是相关的那笔改动。
没有了instrumentation stub的支持,nterp也就无法完整支持调试功能。Google给予的回复也印证了这一点:
We used to use instrumentation stubs which had several problems and added complexity to the code base. So we removed instrumentation stubs. Without having instrumentation stubs we couldn’t support nterp in debuggable runtime.
翻译:我们之前使用instrumentation stub存在一些问题,而且它也增加了代码的复杂性。所以我们删除了它。没有instrumentation stub,我们也就没有办法在debuggable的运行时支持nterp。
2. 性能劣化的原因
谷歌难道不知道这笔改动会对debuggable运行时的性能有影响么?事实上他们不仅知道,而且早就想好了应对方案。
这里首先简单介绍下Android中解释器的演变过程:最早Dalvik时代的解释器为switch interpreter
,它由C++编写,根据字节码的类型,通过switch case来选择需要执行的逻辑。Android 7(N)引入了mterp
,全称叫modular interpreter
,它的核心逻辑由汇编编写,因此性能有了大幅的提升。Android 12(S)引入了nterp
,全称叫new interpreter
(这点存疑,没核实过),它主要省去了之前解释器在执行时需要的跳转函数和中间过程,在nterp
时代,解释器的栈帧和机器码的栈帧表现一致,且同样遵循了机器码的ABI,因此获得了更高的性能。
总结下,在Android 12之后的版本上只存在两种解释器,一种是switch interpreter
,另一种是nterp
(作为mterp
的替代)。switch interpreter
之所以存在,是因为nterp
为了追求性能,并没有处理所有的场景(因为有些场景的支持会显著拉低性能)。在这些场景下,nterp
会退化为switch interpreter
,来满足功能上的准确性。
那么当我们在debuggable运行时将解释器由nterp
切回为switch interpreter
时,性能必然下降。但是!JIT在debuggable环境下依然工作,因此大部分hot method可以被JIT为机器码,这样整体的性能应该不会有太大影响。而问题也恰恰出在这里。
Google认为JIT会对所有的hot method都起作用,但实际上boot image里boot method和zygote里加载的方法却很难触发JIT。原因在于下面这笔改动:
Boot image里的方法和zygote里加载的方法有一个共性,就是它们都会被后续启动的App进程共享。而这些方法绝大多数都提前编译过,运行的是AOT代码,只有少数会走解释器。走解释器的这些方法,每次运行时都会更新自己的hotness count,从而会产生物理页的COW(copy-on-write)动作,这个动作有额外开销。因此Google这里做了优化,既然绝大多数的boot method都是AOT运行的,那么我们不必为少数解释执行的方法产生额外的COW动作,因为COW是整页拷贝,即便我们只改动几个字节。所以一是采用共享counter的方式规避对ArtMethod内部字段的更新,二是提高JIT的阈值。
这样一来,boot method在解释执行时更新的将是一个thread-local counter。当counter减为0时才会更新map里特定方法的counter。最新的AOSP源码上,普通方法的JIT threshold为0x3fff,也即16383。而这些boot method方法的JIT threshold为0x1fff0x3f=516033。差距明显,所以说boot method很难触发JIT。
Debuggable运行时会deoptimize所有的boot method,将它们退化为解释执行,而Google正是忽略了对于这块的考虑,以为所有hot method的JIT都会正常触发。这才导致最后的性能劣化严重。
3. 修复方案
如果一个问题由配置改变导致,那么大家最容易想到的就是回退这个改动。可是这个问题不能这么修复,原因在于instrumentation stub已经从ART中删去,这时在debuggable环境下切回nterp
,会导致调试功能的不完整。
结合上面的分析,最好的修复方案是将boot method的在debuggable环境下的JIT策略恢复正常。因此Google做了这笔修复:
此外,Google还提供了另一笔改动,可以进一步优化debuggable的性能体验。
不过这两笔修复只会出现在Android 15上。如果是海外版本的Android 14设备,Google计划通过com.android.art
apex模块的更新来修复这个问题。但是国内由于网络的问题,Google的推送无法工作,因此需要各个手机厂家来主动合入这两笔改动。我联系了几个厂家的工程师,但可能不全,如果有其他厂家也想修复这个问题,欢迎cherry-pick上述两笔改动。这样广大开发者可能会在未来某次更新后,真正享受到这两笔修复。
4. 性能验证
本地编译了几个不同的版本,用于验证这两笔修复带来的改善。这里特别感谢快手和富途的工程师,他们帮忙做了对比测试,效果十分明显。
配置(快手App) | 启动时间1(非真实数字,归一化) |
---|---|
switch interpreter + 未修复(Android 14默认行为) | 3.26 |
switch interpreter + 已修复(Android 15默认行为) | 1.02 |
nterp interpreter + 未修复 (Android 13默认行为) | 1.07 |
nterp interpreter + 已修复 | 1 |
配置(富途App) | 启动时间2(非真实数字,归一化) |
---|---|
switch interpreter + 未修复(Android 14默认行为) | 2.70 |
switch interpreter + 已修复(Android 15默认行为) | 1.24 |
nterp interpreter + 未修复 (Android 13默认行为) | 1.07 |
nterp interpreter + 已修复 | 1 |
根据上面的数据可以看出,这两笔修复合入后,运行速度基本和Android 13相当。
5. 后记
这个问题对于广大开发者的工作效率有一定影响,属于难受但并不致命的问题。奈何沟通渠道的不顺畅,Google修复了但厂家不知道,开发者报告了但得不到有效反馈,于是它在国内的Android 14设备上变成了没人管的问题。
从个人价值的角度而言,这次修复是Google提供的,测试是快手和富途做的,最终合入也只能依靠手机厂家,而我在其中最多是个“传声筒”的角色,似乎并不起眼。不过我愿意做这样的事情,因为它确实对Android生态有所帮助。所以如果大家后续有类似的问题,也欢迎一起交流。
考虑到和大家沟通的便利性,我申请了一个公众号:芦半山,以后的文章会在两边同步更新,欢迎大家关注。