最近向ART主线提交了一笔改动,用于改进JNI调用的功用。它能够让App的绝大多数 (85%~90%) Java native办法都获益。

整个开发和提交耗时几个月,进程颇多崎岖。写下这篇文章,首要是记载这一路所犯的过错和积累的阅历,给自己和他人留下一些参阅。

目前这笔改动现已合入,之后会出现在Android 15的正式版别中,共触及20个文件和1155行的代码改动。

阅历 | 向AOSP奉献虚拟机的优化
阅历 | 向AOSP奉献虚拟机的优化

缘起

去年年中的时分我写过一篇文章,叫《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而言,任何一个主意的落地都会牵一发而动全身,所以要满足细心。从大的维度来说,这个功用需求从三个方面来考虑:

  1. Boot images需求在zygote发动时加载进来,那么boot JNI stub信息是存在.oat文件里合适仍是.art文件里合适?详细存在文件的什么位置,选用什么样的结构?以及不同文件里的交叉引证如何在加载后修正过来?
  2. 两个native办法经过什么样的数据结构能够快速判定是否能够共用一个JNI stub? 此外,针对不同的架构,是否能够共用的标准也不相同。参数类型共同仅仅最高的标准,咱们是否能够放宽这个标准,让更多native办法获益?但这需求针对每个特定架构去了解机器码的生成进程,然后优化相应的规矩。
  3. 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改动生效,就好像装置运用相同简略。

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个方面去进行测算:

  1. 掩盖面有多广,也即有多少份额的native办法能够从中获益?
  2. 调用的时刻能够削减多少?
  3. 对之后AOT的编译时刻是否有影响?
  4. 对之后生成的odex文件巨细是否有影响?

首先是第一点掩盖份额,经过测验国内top10的运用(排名有打乱),发现掩盖率底子稳定在85%~90%之间,这证明绝大多数的native办法都能够从boot images中找到可用的JNI stub。

测验办法:运用发动并运转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.2s降为了1065.3s,取反核算提高份额的话,能够达到267%。

测验办法:在发动阶段运转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%的改进。

测验办法:对特定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外,这个网页还有两个机器人。

阅历 | 向AOSP奉献虚拟机的优化

它们一个叫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来特别声明。

// namespace change in Android 15
namespace art { ===> namespace art HIDDEN {
// 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才是稳定的,至于内部的完成细节则或许随时改变。当开发者企图绕过约束运用内部不稳定的接口时,他也需求承当这个风险,以及不停适配所带来的人力开支。正如西方谚语所言,欲戴其冠,必承其重。