最近向ART主线提交了一笔改动,用于改善JNI调用的性能。它可以让App的绝大多数 (85%~90%) Java native方法都受益。
整个开发和提交耗时几个月,过程颇多坎坷。写下这篇文章,主要是记录这一路所犯的错误和积累的经验,给自己和别人留下一些参考。
目前这笔改动已经合入,之后会出现在Android 15的正式版本中,共涉及20个文件和1155行的代码改动。
缘起
去年年中的时候我写过一篇文章,叫《ART虚拟机 | JNI优化简史》。当时为了写那篇文章,我看了不少native方法生成的机器码。当时就发现其中有些是完全相同的(事实上JNI跳板函数的生成只取决于参数类型和flag)。相同也就意味着可以共用,于是后面学习JIT代码的时候我就在思考一件事:如果两个native方法可以共用同一个JNI stub(跳板函数可以称为stub,也可以称为trampoline) ,那么当其中一个hotness_count减为0从而触发JIT编译时,另一个方法可以同样享受编译后的机器码么?
现有的机制肯定不行,所以我就问了问Google ART的工程师,看看他们的看法。Google的一个工程师说这个想法可行,但是需要增加一些新的数据结构和开销。另一个工程师说,Boot images里有很多native方法,而且它们都是已经编译过的,其实我们可以想想怎么去复用它们。之后他又补充到:我有这个想法很久了,但是一直没时间去做(Android 15上他们将大部分工作都放在了RISC-V的支持上)。如果有人愿意接手的话,他们非常欢迎。
这确实是个不错的想法,可是要不要接这个活呢?毕竟之前我只向主线提交过一些bug fix和小型改动,像这种虚拟机内部的系统性开发可以说是毫无经验。思来想去,最终还是决定试一试,因为哪怕做不成也可以加深对ART知识的理解。当然,做成更好。
确定方案
对于ART而言,任何一个想法的落地都会牵一发而动全身,所以要足够仔细。从大的维度来说,这个功能需要从三个方面来考虑:
- Boot images需要在zygote启动时加载进来,那么boot JNI stub信息是存在
.oat
文件里合适还是.art
文件里合适?具体存在文件的什么位置,采用什么样的结构?以及不同文件里的交叉引用如何在加载后修正过来? - 两个native方法通过什么样的数据结构可以快速判定是否可以共用一个JNI stub? 此外,针对不同的架构,是否可以共用的标准也不一样。参数类型一致只是最高的标准,我们是否可以放宽这个标准,让更多native方法受益?但这需要针对每个特定架构去了解机器码的生成过程,然后优化相应的规则。
- App的native方法是如何加载的?以及它的entrypoint会在哪些时候发生更改?我们应该在哪些时机去启用这项优化且同时不冲突已有的各种机制(e.g. JIT, AOT, Deoptimize, Intrinsic Method)?
大的方向一旦确定,之后便是具体方案的讨论。讨论阶段Google的工程师给予了充分指导,没有他们的帮助,这项工作根本无法完成(Special thanks to Vladimir Marko, Santiago Aboy Solanes, Mythri Alle and Nicolas Geoffray)。事后我看了下,整个开发过程中不算review comments,单是方案和问题的讨论就多达160多条。
最终方案敲定,接下来便是coding阶段。
下载代码
Coding之前需要有一份代码。当然,我指的不是通常意义上的AOSP源码下载,而是ART单模块的代码下载。
早在Android 10的时候,Android就引入了APEX机制,试图让系统模块可以像App一样被安装和更新,ART也是其中之一。这一机制大大简化了ART的开发流程。
传统方式中,我们需要下载整个AOSP源码并让其保持最新,之后找一台可以支持它的硬件设备进行开发和测试。但有了APEX机制后我们不再需要这样。现在,我们可以参照art/build/README.md进行单模块代码的下载。当开发工作完成后,我们可以用任意一台Android 10~15的设备(user-debug和eng均可)进行验证和测试。譬如以下两条命令即可在不烧机的前提下让ART改动生效,就好像安装应用一样简单。
bash
adb install out/dist/com.android.art.apex
adb reboot
话虽这么说,但是最初开发时我也走了弯路。由于顾虑APEX机制在国内设备上的兼容性,最初我只选择在Android 14的大版本上进行开发,其中的ART代码也并非最新。直到中期某次闲暇之余,我试了试APEX单模块的下载、编译和安装,发现过程极其丝滑,这才意识到Google为了让开发变得方便做了多少努力。
配置环境
由于我是远程到Linux环境进行开发的,所以并没有尝试VSCode、Android Studio等图形化IDE,而是直接使用的VIM。未经配置的VIM用起来十分困难,甚至连拼写错误都需要等到编译时才发现。等到吭哧吭哧地弄出第一个版本后,我才意识到这种开发效率实在太低。
于是花了些时间配置VIM(NeoVim)。整个过程十分有趣,让你感觉一切尽在掌控之中。对于插件中不符合习惯的地方,也可以方便地修改代码来调整。此外我还配置了tmux、lazygit和fzf,让开发的效率直线上升。最后是terminal的选择,试了不少但大多数对nerd font的支持都不好。最终还是Windows默认的terminal完美契合了需求,于是果断放弃putty等常规的ssh工具。整个配置环节也让我深刻体会到奥卡姆剃刀定律:如无必要,勿增实体。
通过一段时间的使用,我发现这套环境用起来很方便,跟现代IDE基本无异。
开发
开发过程中遭遇的坑实在太多,经常是写完代码一脸自信,运行起来啪啪打脸。总结下来就是对ART的理解不够全面。由于这个feature影响的机制较多,经常是改动A牵扯到B,有时还冒出一个根本不了解的C。于是整个开发过程的大部分时间都在解bug。更为麻烦的是,虚拟机的bug具有一定的传导性,你看到的log和问题的根本原因可能完全不关联,而是由于各种机制的牵连作用传导过去的。这种时候只能不断地调整代码去尝试可能的方向。
另外,整个开发过程中给我最大的教训就是测试代码写的太晚。按照以往的经验,我先来搞开发,等开发搞得差不多了再补上测试代码。可是现在回过头来看,这是彻彻底底的失策。
当时我需要根据不同的架构(主要是arm64和x86_64,后续会补充riscv)来优化hash和equal的策略,以便让更多的方法被这个feature覆盖到。但这个调整策略的过程是痛苦的,任何一些细微的改动或是写法上的不严谨,都可能让本不该使用同一个JNI stub的方法错误地共用了同一个stub,从而产生各种奇奇怪怪又难以调试的问题。
等我开始写测试代码的时候才反应过来:如果早早地把它准备好,那么上述这种问题不仅能更容易地暴露出来,而且精心打印的机器码也能够让我一眼看出汇编的差异,不用再在那些奇怪的log中痛苦挣扎。
测试
ART的测试框架十分完备,分为两种类型。一种是利用GTest框架从native层面(C++)对ART进行的解剖式测试。另一种是将ART当作一个整体,在上面跑各种Java或Smali代码,试验虚拟机各种特性的测试,称为run-tests。
这个feature的测试代码主要为GTest类型,重点放在了共用策略和最终生成的机器码是否吻合上。具体而言,就是共用策略说可以共用,那么生成的机器码就得完全一致;共用策略说不可以共用,那么生成的机器码肯定有些差别。
虽然我没有写run-tests的测试用例,但是ART中已经存在的几千个run-tests依然可能会受到这笔改动的影响。譬如deoptimize和jit的两个测试用例就恰好被这笔改动影响到。
那么测试到底跑在什么机器上呢?由于这笔改动和架构有关,所以我需要尽可能多的在不同架构的设备上去测试。首先是host上的测试,我的Linux主机架构为x86_64,当然也可以兼容跑x86。常规而言,Host测试最为方便,输出的log也最全,因此被视为测试的首选。接着是target上的测试,找一台arm64可以兼容跑arm的手机,参照《ART Chroot-Based On-Device Testing》,便可以实现在不影响手机已有系统的前提下进行测试。具体而言,它采用chroot的方式将需要测试的ART模块安装在data目录,这样不会干扰到手机上已经运行的系统。
按照上面的操作,便可以将4个架构测试到位,分别是x86、x86_64、arm和arm64。至于剩下的riscv,由于没有实际设备只能作罢,留给提交后Google的测试服务器去测试。
当然,除了自动化测试框架以外,安装新的ART模块并进行人工测试也是必要的一环。
测算效果
功能验证通过后,接下来就是测算效果的阶段,这需要一些数据做支撑。最理想的情况当然是有现成的benchmark可以使用,但是未能如愿。在跟Google的工程师沟通完后,我决定从4个方面去进行测算:
- 覆盖面有多广,也即有多少比例的native方法可以从中受益?
- 调用的时间能够减少多少?
- 对之后AOT的编译时间是否有影响?
- 对之后生成的odex文件大小是否有影响?
首先是第一点覆盖比例,通过测试国内top10的应用(排名有打乱),发现覆盖率基本稳定在85%~90%之间,这证明绝大多数的native方法都可以从boot images中找到可用的JNI stub。
ini
测试方法:应用启动并运行30秒
(可以找到Boot JNI stub的方法数/App中native方法的总数)
app1: 1055/1206 = 87.48%
app2: 765/884 = 86.54%
app3: 1267/1414 = 89.60%
app4: 1577/1759 = 89.65%
app5: 1698/1860 = 91.29%
app6: 2528/2787 = 90.71%
app7: 1058/1218 = 86.86%
app8: 952/1092 = 87.18%
app9: 1343/1483 = 90.56%
app10: 2990/3492 = 85.62%
接着是第二点调用时间,它可以微观来看,也可以宏观来看。微观就是只针对JNI调用去测算时间,看看这项优化提升的比例。而宏观则是找一个日常使用的场景,看看整体时间的变化。由于JNI的参数类型各种各样,所以选择了简单的addOne(Object, int)
方法去进行基本的测算(测了几个复杂参数的,和简单方法差异并不是很大)。测下来50000次调用的时间从3919.2μs降为了1065.3μs,取反计算提升比例的话,可以达到267%。
erlang
测试方法:在启动阶段运行addOne(Object, int)方法,测算需要的耗时,单位为μs
Number of runs before after 优化比例(时间取反计算提升比例)
5000 398.70 124.94 219.11%
10000 792.21 234.23 238.22%
50000 3919.20 1065.30 267.90%
宏观选择了应用首次启动的场景,测下来提升并不明显,可能的原因是JNI调用在整体应用启动时间中的占比本来就很低。再加上通过am start
测量的启动时间每次都有波动,感觉即便有微弱的收益也掩埋在了噪声里。总之,收益甚微。
这里穿插一个我所犯的错误。本来我打算统计启动时所有JNI stub的调用时间,于是在compiled JNI stub和generic JNI stub中都插了桩,在每次stub调用时都去记录它,然后按线程和进程去进行时间统计。结果发现这种方案根本不可行,原因是统计时间的系统调用本身就有耗时,它的耗时甚至比单次stub的调用耗时还要大。这就属于观测行为本身对观测结果产生了重大影响,致使结果不可信。
然后是第三点对AOT编译时间的影响,测算了两个应用,编译时间大致有1%~2%的改善。
vbnet
测试方法:对特定App运行'cmd package compile -f -m speed -v {app_name}'命令,记录dex2oatCpuTimeMillisecond,单位为ms
before after 优化比例(时间取反计算提升比例)
App1: 511990 504290 1.53%
App2: 138160 134960 2.37%
最后是第四点对odex文件大小的影响,文件大小虽然是稳定可测的,但改善效果十分微弱,原因是odex文件本身就有去重,不同方法生成的机器码如果一致那么文件中只会保留一份,但是编译时间会随着方法的增多而增加。
总的来说,这项优化从微观角度改善明显,但宏观角度改善微弱。或许一些JNI调用的重度场景会有较为明显的收益。
优化代码格式和性能
有了测算的数据做支撑,接下来可以向AOSP进行正式的提交。就在我满心欢喜等待着改动被review通过时,Google的工程师一下子回复了30多个comments,然后还说了下面这段话。
I have previously focused only on correctness but, as I'm reviewing the code this time, I'll have some suggestions related to performance and making the code tidy, such as not putting too much code in a header file, splitting and renaming things.
翻译:之前我关注的重点是代码的正确性,但是这次review时,我会提出一些与性能和代码整洁有关的建议,比如:不要将过多的代码放入头文件,对某些代码进行拆分以及重命名一些东西。
看来合入并不容易。功能的正确只是第一步,代码的写法也很关键。这里既有ART内部约定俗成的一些写法规范,也有后续扩展维护的一些考虑,当然还有不同写法的性能抉择。这里举个简单的例子:
根据不同的架构来选择不同的equal策略,写法上由最初的switch case变成了模板函数。由于模板传入的isa在编译期间可以确定,因此根据isa返回的float register上限值也可以定义为constexpr
,也即编译期常量。这样便不用在运行时根据isa再做选择,从而提升代码的执行效率。
总结下来,Google的工程师给出的反馈非常仔细且具体,我想这也是保证ART代码质量的重要因素。
提交
待到一切准备就绪后,再次提交。这里重点介绍下提交流程,其中有些细节并无公开文档记载,或许会有些价值。
根据官方文档可知,我们提交前一定要先repo sync。这是因为开发期间主线上产生了很多新的改动,有些可能会和我们的改动存在冲突。因此提交前必须要拉取最新的改动,做一下rebase保证没有冲突再提交。
提交采用repo upload
命令,顺利提交后就会返回一个android-review.googlesource.com
的链接。这个网页就是我们和reviewer进行互动,获取各种+1、+2的地方。除了人工的+1、+2外,这个网页还有两个机器人。
它们一个叫Lint
,另一个叫Treehugger
。
Lint的英文释义为:
small loose pieces of cotton, wool, etc. that stick on the surface of a fabric, etc.
翻译:粘在织物等表面的棉花、羊毛等小散件。
它的主要作用是从文字层面检查代码中是否有拼写和格式错误,包括文件头部的license内容。每当有新的patch更新时,Lint都会启动。如果没有检查出任何错误,那么Lint
项和Open-Source-Licensing
项都会被置上+1
。
Treehugger的英文释义为:
an environmental campaigner (used in reference to the practice of embracing a tree in an attempt to prevent it from being felled).
翻译:环保运动者(用于指拥抱一棵树,试图阻止其被砍伐的做法)。
它的主要作用是跑一些自动化测试,检查代码运行起来有没有问题。如果检测到问题,Presubmit-Verified
项就会被置上-1
或-2
,从而保护主线仓库不受有问题代码的入侵。如果没有问题,Presubmit-Verified
项就会被置上+2
。Treehugger一般在Google工程师+2
后由他们来启动,原因是Presubmit-Verified
的+2
会在两个工作日后过期,所以一般把它当作merge前的最后一道工序(当然我们也可以auto-submit来主动触发它,但刚说了它是会过期的)。
最近Google又添加了一个Performance机器人,用于检测是否有性能劣化的发生。它一般跟随Treehugger一起启动,检测没有问题后,Performance
项就会被置上+1
或+2
。
当所有的submit requirements都通过后,代码就可以被合入了。
回撤
但是合入之后并不意味着万事大吉,因为Google还有个一持续集成系统叫做LUCI。
LUCI: the Layered Universal Continuous Integration system
翻译:多层通用持续集成系统
对ART而言,我们可以查看这个网站检查每笔改动的测试结果。它会在每笔改动合入后多架构多平台地跑更丰富的测试,一旦检测出现问题,Google的工程师们就会收到通知。如果确认是我们的改动导致的,那么他们会提交一笔revert,将我们的提交覆盖。很不幸,这个feature也经历了revert,原因是本地默认的测试方式没有测到debuggable选项。
当然,被revert也不必沮丧,这也侧面说明了ART主线的健壮性。仔细查看LUCI bot反馈的错误信息,最终发现这个feature和现有的调试机制有些冲突。为了解决这个冲突,又花了一些时间来学习调试和deoptimize的相关原理。所以与其说这是一次开发,倒不如说这是一次深入学习的机会。
只有充分理解原理才能设计出相对优雅的代码。否则如果只是头痛医头,那么代码在演进的过程中将会遇到更多问题。冲突解决完之后便可以再次提交,也称为Reland
。
至此,一个优化的改动才算最终被合入。可是故事到这里还没结束,未来更大规模的测试和真机运行还在等待着它。
后记
针对国内特定的App生态,过程中有两件事记录在此,全当看个乐。
一个是某些hook方案可能受到的影响。国内很多App都喜欢使用hook的方式去满足一些业务需求,比如热更新或动态化。有一类hook的思路是去修改方法的机器码,比如优秀的inline hook库ShadowHook
,不过它主要hook C/C++方法,所以不会受影响。但如果有人尝试去修改Java方法生成的机器码,那么一定得小心。因为Java生成的机器码可能被复用,一旦修改之后将会影响多个方法(即便没有这笔改动,AOT和JIT的代码也会有复用机制)。
二是Android 15中,art/runtime目录下的所有namespace都被定义为HIDDEN,这个feature新增的文件也遵循了此项要求。而如果某个符号被外部引用,那么它要由EXPORT
来特别声明。
arduino
// namespace change in Android 15
namespace art { ===> namespace art HIDDEN {
csharp
// EXPORT example in art/runtime
EXPORT jobject GetSystemThreadGroup() const;
这将导致很多原本暴露出来的symbol现在被隐藏了,而国内有些App喜欢拿着这些symbol去篡改虚拟机的行为。所以在Android 15上,他们解析symbol的方法可能需要调整。
虽然这些symbol被隐藏了,但是由于打印调用栈需求的存在,它们其实还藏身在.gnu_debugdata
段中。而这部分内容是无论如何也不会被删去的,否则虚拟机的调用栈将没有函数名信息。目前已经有开源库xDl可以从.gnu_debugdata
中解析symbol,如果大家在Android 15上碰到问题,可以尝试下这个库(感谢维术文章的介绍)。至于Google为什么进行这项修改,我特意问了下,他们给出的答复是:
There are two reasons why we're trying to hide the symbols. It makes libart.so smaller and it hides implementation details that can easily change. The latter is supposed to reduce the chance that DRM/obfuscation libraries shall break when we change these implementation details.
翻译:我们试图隐藏这些符号主要有两个原因,一个是让libart.so文件变得更小,另一个是隐藏那些容易变化的实现细节。后者主要是为了减少一些DRM/混淆库在我们调整内部实现细节后产生的问题(因为它们经常依赖这些实现细节来工作,一旦细节改变就容易使它们功能出错)。
当我提到国内厂家可能采用一些方式绕过这个限制时,他们的看法如下:
But if they work around that by using the debug data (which we definitely want to keep for logging stack traces), their apps/libraries shall keep breaking with every major Android release. And we may start releasing substantial ART changes even more frequently in the future.
翻译:不过如果他们通过使用调试数据(调试数据不会从库中删除,因为我们需要它来保证栈回溯的完整打印)来绕开这个限制,那么他们的App/库就可能在每次大的版本更新后发生崩溃。并且今后我们可能会更加频繁地发布ART的修改。
总的来说,从Google的角度他们似乎并不介意对虚拟机行为的篡改,而只是担心版本更迭过程中对内部机制的修改可能会导致这些篡改行为失效,以及所引发的各种奇奇怪怪的崩溃(这一点我深有体会,之前版本升级中碰到过几个难搞的虚拟机问题,都和三方加固方案有关)。从软件设计的角度,只有公开的API才是稳定的,至于内部的实现细节则可能随时变化。当开发者试图绕过限制使用内部不稳定的接口时,他也需要承担这个风险,以及不停适配所带来的人力开销。正如西方谚语所言,欲戴其冠,必承其重。