写点什么

[译] R8 优化:Null 数据分析 (第一篇)

用户头像
Antway
关注
发布于: 4 小时前

本文原文出自 jakewharton 关于 D8R8 系列文章第五篇。



上篇文章中第一次介绍到 R8 的优化,本篇文章将介绍 R8 针对 Null Data(空数据)的优化,让我们一起开始吧!

1. R8

fun <T : Any> coalesce(a: T?, b: T?): T? = a ?: b
fun main(vararg args: String) { println(coalesce("one", "two")) println(coalesce(null, "two"))}
复制代码


在上面示例中,coalesce 函数根据参数 a 是否为 null 来进行返回,如果 a 不为 null,则返回 a,反之返回 b。上面的输出结果如下:


onetwo
复制代码


在编译时,如果一个函数的函数体很简短,R8ProGuard 会在该函数的调用处将函数体内置到调用函数中。因为 coalesce 很简短,所以它的函数体会被内联嵌套在所有调用它的地方。


fun main(vararg args: String) {  println("one" ?: "two")  println(null ?: "two")}
复制代码


实际上,Kotlin 编辑器能够在编译时期确认 ?: 操作符运算结果,我们编译上面的代码,查看编译打包后的字节码来验证。


[000180] NullsKt.main:([Ljava/lang/String;)V0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: const-string v0, "one"0004: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V0007: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0009: const-string v0, "two"000b: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V000e: return-void
复制代码


如我们所料,字节码中并没有进行条件判断,而是直接调用 println 方法输出 onetwo。但是,由于优化发生在 R8 内部,而不是在 Kotlin 编译器之前,所以实际的 Dalvik 字节码包含条件。


[000144] NullsKt.main:([Ljava/lang/String;)V0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: const-string v0, "one"0004: if-nez v0, 00060006: const-string v0, "two"0008: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V000b: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;000d: const/4 v0, #int 0000f: if-nez v0, 00100010: const-string v0, "two"0012: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V0015: return-void
复制代码


0002 处加载常量 one,然后 0004 判断是否为空,所以一直是不为空的,这样就导致 0006 处的代成成为 dead code,无法加载 two。同样的道理对于 000d 处加载 0(代表 null),然后在 000f 处进行非空检查,这个会一直失败,因为一直为空,然后直接去执行 0010 处的代码。


在上一篇文章中,我们讲到 R8IR 层面优化代码,IR 使用 SSA 来加强一些优化项。使用 SSAR8 可以决定程序中的数据的处理流程。对于上面代码第一个 println 的内联数据处理流程可简要描述如下。



SSA 的基本特性是每个变量只赋值一次。这也是字符串 two 赋值给 y 而不赋值给 x 的原因。z 通过 [Φ(欧拉函数)](https://baike.baidu.com/item/%E6%AC%A7%E6%8B%89%E5%87%BD%E6%95%B0) 来选择 xy 的值。结合前面的字节码,可以看到 xyz 最后都会赋值给寄存器地址 v0,而寄存器地址 v0 会被覆盖。注意单次分配只针对 IR 层!


如果我们针对上面的流程图增加一些空信息,因为 xy 都是用常量初始化的,所以它们一定不为空。进而,z 也是不为空的,


x 不为 null 时,R8 知道 if-nez 字节码检查 x 非空是恒成立的,所以这个判断是无用的。同样,针对 y 的赋值也是无用的。



通过上面的分析,我们知道 false 分支判断的字节码是无用的 dead-code,所以我们对分支进行优化,删除无用的判断分支。



从图中可以看到 z 的值就是 Φ 函数对单变量 x 的运算结果,所以我们可以将 z 替换为 x



从图中可以看到剩下的部分分别是:一个指向 System.outw 变量、将 one 赋值给 xw 调用 println 函数输出 x


上面的介绍是针对示例代码中的第一个 println 函数。第二个 println 函数由于初始化时是 null,所以跟上面的过程是相反的。x 初始化为 null,进行非空检查一直是 false,所以 y 一直是 two



通过使用 SSA IRR8 能够根据条件进行优化,删除无用代码。


$ kotlinc *.kt
$ cat rules.txt-keepclasseswithmembers class * { public static void main(java.lang.String[]); } -dontobfuscate
$ java -jar r8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ --pg-conf rules.txt \ *.class kotlin-stdlib-1.3.11.jar
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000340] NullsKt.main:([Ljava/lang/String;)V0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: const-string v0, "one"0004: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V0007: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0009: const-string v0, "two"000b: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V000e: return-void
复制代码


通过上面的字节码可以看到完全和我们一开始的示例分析一样。

2. Analysis Inside D8(D8 内部分析)

针对上面的例子,我尝试使用 Java 代码进行实现来分析看看。


class Nulls {  public static void main(String... args) {    Object first = "one";    if (first == null) {      first = "two";    }    System.out.println(first);    Object second = null;    if (second == null) {      second = "two";    }    System.out.println(second);  }}
复制代码


我们对上面的例子进行编译,然后通过 d8 进行打包,最后通过 dumpdex 查看 dex 字节码,发现判断条件仍然被消除了。


$ javac *.java
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000224] Nulls.main:([Ljava/lang/String;)V0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: const-string v0, "one"0004: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V0007: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0009: const-string v0, "two"000b: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V000e: return-void
复制代码


发生这种情况的原因是 D8 同样使用 IR 进行优化,并且仍然存在空信息。即使不进行任何 R8 优化,如果在 IR 中存在的判断条件始终为 truefalseD8 同样会进行相关代码的清除优化。


如果我们使用不包含 IRdx 工具编译,则会发现字节码中仍然存在条件判断和无用的代码。


$ $ANDROID_HOME/build-tools/28.0.3/dx --dex --output=classes.dex *.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex[000204] Nulls.main:([Ljava/lang/String;)V0000: const-string v0, "one"0002: if-nez v0, 00060004: const-string v0, "two"0006: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0008: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V000b: const/4 v0, #int 0000c: if-nez v0, 0010000e: const-string v0, "two"0010: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0012: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V0015: return-void
复制代码


因此,R8 对内联嵌套进行优化确实有很好的作用,但是如果源代码中存在常量条件和死代码,D8 在编译时同样会消除它们。

3. 总结

这篇文章只触及 R8 内部数据流分析的表面。下一篇文章将继续扩展可空性分析,讨论 Kotlin 如何在运行时强制实施可空性约束。

用户头像

Antway

关注

持续精进,尽管很慢 2019.05.27 加入

专注开源库

评论

发布
暂无评论
[译] R8 优化:Null 数据分析 (第一篇)