原文做者: Jake Whartonjava
原文标题:Which is better on Android: divide by 2 or shift by 1?android
原文地址:https://jakewharton.com/which-is-better-on-android-divide-by-two-or-shift-by-one/web
译者:秉心说shell
我一直在尝试将 AndroidX collection library 移植到 Kotlin multiplatform,来测试二进制兼容性,性能,易用性和不一样的内存模型。类库中的一些数据结构使用基于数组实现的二叉树来存储元素。在 Java 代码中有许多地方使用 移位操做 来代替二次幂的乘除法。当移植到 Kotlin 时,这些代码会被转化为略显变扭的中缀操做符,这有点混淆了代码意图。api
关于移位运算和乘/除法谁的性能更好,我作过一些调研,大多数人都据说过 “移位运算性能更好”,但也对其真实性保持质疑。一些人认为代码运行到 CPU 以前,编译器可能会作一些优化。数组
为了知足个人好奇心,同时避免使用 Kotlin 的中缀移位操做符,我会来回答谁更好以及一些相关问题。Let's go !安全
译者注:Jake Wharton 吐槽的 Kotlin 移位操做是这么写的:
i shr 1
、i shl 1
数据结构
在咱们的代码被 CPU 执行以前,有如下几个重要的编译器:javac/kotlinc
、D八、R8
和 ART
。jvm
其中的每一步都有机会作优化,可是它们作了吗?编辑器
class Example {
static int multiply(int value) {
return value * 2;
}
static int divide(int value) {
return value / 2;
}
static int shiftLeft(int value) {
return value << 1;
}
static int shiftRight(int value) {
return value >> 1;
}
}
复制代码
在 JDK 14 下编译上面的代码,并经过 javap
展现字节码。
$ javac Example.java
$ javap -c Example
Compiled from "Example.java"
class Example {
static int multiply(int);
Code:
0: iload_0
1: iconst_2
2: imul
3: ireturn static int divide(int); Code: 0: iload_0 1: iconst_2 2: idiv 3: ireturn static int shiftLeft(int); Code: 0: iload_0 1: iconst_1 2: ishl 3: ireturn static int shiftRight(int); Code: 0: iload_0 1: iconst_1 2: ishr 3: ireturn } 复制代码
每一个方法都以 iload_0
指令开头,表示加载第一个参数。乘法和除法都是用 iconst_2
指令来加载字面量 2 。而后分别执行 imul
和 idiv
指令来进行 int 类型的乘除法。移位操做也是先加载字面量 1,而后利用 ishl
和 ishr
指令进行移位运算。
这里没有进行任何优化,可是若是你对 java 有所了解的话,也不会感到意外。javac
并非一个会进行优化的编译器,而是把大部分工做留给了 JVM 上的运行时编译器或者 AOT 。
fun multiply(value: Int) = value * 2
fun divide(value: Int) = value / 2
fun shiftLeft(value: Int) = value shl 1
fun shiftRight(value: Int) = value shr 1
复制代码
在 Kotlin 1.4-M1 版本下经过 kotlinc
将 Kotlin 编译成 Java 字节码,再使用 javap
查看。
$ kotlinc Example.kt
$ javap -c ExampleKt
Compiled from "Example.kt"
public final class ExampleKt {
public static final int multiply(int);
Code:
0: iload_0
1: iconst_2
2: imul
3: ireturn public static final int divide(int); Code: 0: iload_0 1: iconst_2 2: idiv 3: ireturn public static final int shiftLeft(int); Code: 0: iload_0 1: iconst_1 2: ishl 3: ireturn public static final int shiftRight(int); Code: 0: iload_0 1: iconst_1 2: ishr 3: ireturn } 复制代码
输出结果和 Java 彻底一致。
This is using the original JVM backend of Kotlin, but using the forthcoming IR-based backend (via -Xuse-ir) also produces the same output.
上面这句裱起来,由于我看不懂 ~
使用最新的 D8 编译器将上面示例的 Kotlin 代码转换的字节码生成 DEX 文件。
$ java -jar $R8_HOME/build/libs/d8.jar \ --release \ --output . \ ExampleKt.class $ dexdump -d classes.dex Opened 'classes.dex', DEX version '035' Class #0 - Class descriptor : 'LExampleKt;' Access flags : 0x0011 (PUBLIC FINAL) Superclass : 'Ljava/lang/Object;' Direct methods - #0 : (in LExampleKt;) name : 'divide' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000118: |[000118] ExampleKt.divide:(I)I 000128: db00 0102 |0000: div-int/lit8 v0, v1, #int 2 // #02 00012c: 0f00 |0002: return v0#1 : (in LExampleKt;) name : 'multiply' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码
000130: |[000130] ExampleKt.multiply:(I)I 000140: da00 0102 |0000: mul-int/lit8 v0, v1, #int 2 // #02 000144: 0f00 |0002: return v0
#2 : (in LExampleKt;) name : 'shiftLeft' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码
000148: |[000148] ExampleKt.shiftLeft:(I)I 000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 00015c: 0f00 |0002: return v0
复制代码#1 : (in LExampleKt;) name : 'multiply' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码#2 : (in LExampleKt;) name : 'shiftLeft' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码#3 : (in LExampleKt;) name : 'shiftRight' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码000160: |[000160] ExampleKt.shiftRight:(I)I 000170: e100 0101 |0000: shr-int/lit8 v0, v1, #int 1 // #01 000174: 0f00 |0002: return v0 复制代码#3 : (in LExampleKt;) name : 'shiftRight' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码
(略微优化了输出结果)
Dalvik 字节码是基于寄存器的,Java 字节码是基于栈的。最终,每一个方法实际上都仅仅使用了一个字节码来操做相关联的整型数运算。它们都使用了 v1 寄存器来保存第一个方法参数,另外还须要一个字面量 1 或者 2。
因此不会产生任何改变,D8 并非一个优化编译器(尽管它能够作 method-local optimization) 。
为了运行 R8,咱们须要配置混淆规则防止咱们的代码被移除。
-keep,allowoptimization class ExampleKt {
<methods>;
}
复制代码
上面的规则经过 --pg-conf
参数传递。
$ java -jar $R8_HOME/build/libs/r8.jar \
--lib $ANDROID_HOME/platforms/android-29/android.jar \
--release \
--pg-conf rules.txt \
--output . \
ExampleKt.class
$ dexdump -d classes.dex
Opened 'classes.dex', DEX version '035'
Class #0 -
Class descriptor : 'LExampleKt;'
Access flags : 0x0011 (PUBLIC FINAL)
Superclass : 'Ljava/lang/Object;'
Direct methods -
#0 : (in LExampleKt;)
name : 'divide'
type : '(I)I'
access : 0x0019 (PUBLIC STATIC FINAL)
code -
000118: |[000118] ExampleKt.divide:(I)I
000128: db00 0102 |0000: div-int/lit8 v0, v1, #int 2 // #02
00012c: 0f00 |0002: return v0
#1 : (in LExampleKt;) name : 'multiply' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000130: |[000130] ExampleKt.multiply:(I)I 000140: da00 0102 |0000: mul-int/lit8 v0, v1, #int 2 // #02 000144: 0f00 |0002: return v0 #2 : (in LExampleKt;) name : 'shiftLeft' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000148: |[000148] ExampleKt.shiftLeft:(I)I 000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 00015c: 0f00 |0002: return v0 #3 : (in LExampleKt;) name : 'shiftRight' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 000160: |[000160] ExampleKt.shiftRight:(I)I 000170: e100 0101 |0000: shr-int/lit8 v0, v1, #int 1 // #01 000174: 0f00 |0002: return v0 复制代码
和 D8 的输出如出一辙。
使用上面 R8 输出的 Dalvik 字节码做为 ART 的输入,在 Android 10 的 x86 虚拟机上运行。
$ adb push classes.dex /sdcard/classes.dex
$ adb shell
generic_x86:/ $ su
generic_x86:/ # dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
generic_x86:/ # oatdump --oat-file=/sdcard/classes.oat
OatDexFile:
0: LExampleKt; (offset=0x000003c0) (type_idx=1) (Initialized) (OatClassAllCompiled)
0: int ExampleKt.divide(int) (dex_method_idx=0)
CODE: (code_offset=0x00001010 size_offset=0x0000100c size=15)...
0x00001010: 89C8 mov eax, ecx
0x00001012: 8D5001 lea edx, [eax + 1]
0x00001015: 85C0 test eax, eax
0x00001017: 0F4DD0 cmovnl/ge edx, eax
0x0000101a: D1FA sar edx
0x0000101c: 89D0 mov eax, edx
0x0000101e: C3 ret
1: int ExampleKt.multiply(int) (dex_method_idx=1)
CODE: (code_offset=0x00001030 size_offset=0x0000102c size=5)...
0x00001030: D1E1 shl ecx
0x00001032: 89C8 mov eax, ecx
0x00001034: C3 ret
2: int ExampleKt.shiftLeft(int) (dex_method_idx=2)
CODE: (code_offset=0x00001030 size_offset=0x0000102c size=5)...
0x00001030: D1E1 shl ecx
0x00001032: 89C8 mov eax, ecx
0x00001034: C3 ret
3: int ExampleKt.shiftRight(int) (dex_method_idx=3)
CODE: (code_offset=0x00001040 size_offset=0x0000103c size=5)...
0x00001040: D1F9 sar ecx
0x00001042: 89C8 mov eax, ecx
0x00001044: C3 ret
复制代码
(略微优化了输出结果)
x86 的汇编代码代表 ART 介入了数学运算,并使用移位操做代替了其中的一部分。
首先,multiply
和 shiftLeft
如今具备一样的实现,它们都使用 shl
来进行左移一位的操做。除此以外,若是你查看文件偏移量(最左边一列)的话,你会发现是彻底同样的。ART 识别到了这两个方法具备同样的方法体,并在编译成 x86 汇编代码时进行了去重操做。
而后,divide
和 shiftRight
的实现是不同的,它们没有共同使用 sar
来进行右移一位的操做。divide
方法在调用 sar
以前额外使用了四条指令,来处理输入是负数的状况。
在 Android 10 Pixel4 的设备上执行相同的指令,来看看 ART 是如何将代码编译成 ARM 汇编代码的。
OatDexFile:
0: LExampleKt; (offset=0x000005a4) (type_idx=1) (Verified) (OatClassAllCompiled)
0: int ExampleKt.divide(int) (dex_mmultiply and shiftLeft ethod_idx=0)
CODE: (code_offset=0x00001009 size_offset=0x00001004 size=10)...
0x00001008: 0fc8 lsrs r0, r1, #31
0x0000100a: 1841 adds r1, r0, r1
0x0000100c: 1049 asrs r1, #1
0x0000100e: 4608 mov r0, r1
0x00001010: 4770 bx lr
1: int ExampleKt.multiply(int) (dex_method_idx=1)
CODE: (code_offset=0x00001021 size_offset=0x0000101c size=4)...
0x00001020: 0048 lsls r0, r1, #1
0x00001022: 4770 bx lr
2: int ExampleKt.shiftLeft(int) (dex_method_idx=2)
CODE: (code_offset=0x00001021 size_offset=0x0000101c size=4)...
0x00001020: 0048 lsls r0, r1, #1
0x00001022: 4770 bx lr
3: int ExampleKt.shiftRight(int) (dex_method_idx=3)
CODE: (code_offset=0x00001031 size_offset=0x0000102c size=4)...
0x00001030: 1048 asrs r0, r1, #1
0x00001032: 4770 bx lr
复制代码
一样的,multiply
和 shiftLeft
使用 lsls
来完成左移一位的操做并去除了重复方法体。shiftRight
经过 asrs
指令完成右移,而除法使用了另外一个右移指令 lsrs
来处理输入是负数的状况。
到此为止,咱们能够确定的说,使用 value << 1
来代替 value * 2
不会带来任何好处。 中止在算数运算中作这样的事情吧,仅在严格要求按位运算的状况下保留。
可是,value / 2
和 value >> 1
仍然会产生不一样的汇编指令,所以也会有不同的性能表现。值得庆幸的是,value / 2
并不会进行通用的除法运算,仍然是基于移位操做,所以它们的性能差别可能并不大。
为了肯定移位操做和除法运算谁更快,我使用了 Jetpack benchmark 进行了测试。
class DivideOrShiftTest {
@JvmField @Rule val benchmark = BenchmarkRule()
@Test fun divide() { val value = "4".toInt() // Ensure not a constant. var result = 0 benchmark.measureRepeated { result = value / 2 } println(result) // Ensure D8 keeps computation. } @Test fun shift() { val value = "4".toInt() // Ensure not a constant. var result = 0 benchmark.measureRepeated { result = value shr 1 } println(result) // Ensure D8 keeps computation. } } 复制代码
我没有 x86 设备,因此我在 Android 10 Pixel3 上进行了测试,结果以下:
android.studio.display.benchmark=4 ns DivideOrShiftTest.divide count=4006 mean=4 median=4 min=4 standardDeviation=0 复制代码android.studio.display.benchmark=3 ns DivideOrShiftTest.shift count=3943 mean=3 median=3 min=3 standardDeviation=0 复制代码
使用除法和移位实际上并无什么区别,它们的差距是纳秒级的。使用负数的话,结果不会有任何差别。
到此为止,咱们能够确定的说,使用 value >> 1
来代替 value / 2
不会带来任何好处。 中止在算数运算中作这样的事情吧,仅在严格要求按位运算的状况下保留。
对于同一操做有两种表达方式的话,应该选择性能更优的。若是性能相同,就应该选择能下降 Apk 体积的。
如今咱们都知道了 value * 2
和 value << 1
在 ART 上产生了相同的汇编代码。所以,若是哪种可以在 Dalvik 上更加节省空间,咱们就应该毫无疑问的使用它来代替另外一种写法。让咱们来看看 D8 的输出,它也产生了相同大小的字节码:
#1 : (in LExampleKt;) name : 'multiply' ⋮ 000140: da00 0102 |0000: mul-int/lit8 v0, v1, #int 2 // #02复制代码#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 复制代码000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 复制代码#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 复制代码
乘法有可能会耗费更多的空间用来存储字面量。比较一下 value * 32_768
和 value << 15
。
#1 : (in LExampleKt;) name : 'multiply' ⋮ 000128: 1400 0080 0000 |0000: const v0, #float 0.000000 // #00008000 00012e: 9201 0100 |0003: mul-int v1, v1, v0复制代码#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 复制代码00015c: e000 000f |0000: shl-int/lit8 v0, v0, #int 15 // #0f 复制代码#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 复制代码
我在 D8 上提过这个 issue,但我强烈怀疑出现这一状况的几率为 0,因此这并不值得。
D8 和 R8 的输出也代表,对于 Dalvik 来讲,value / 2
和 value >> 1
的代价是相同的。
#0 : (in LExampleKt;) name : 'divide' ⋮ 000128: db00 0102 |0000: div-int/lit8 v0, v1, #int 2 // #02复制代码#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 复制代码000158: e000 0101 |0000: shl-int/lit8 v0, v1, #int 1 // #01 复制代码#2 : (in LExampleKt;) name : 'shiftLeft' ⋮ 复制代码
当字面量大小达到 32768
时,上面的字节码大小也会发生变化。因为负数的缘由,无条件的使用右移来代替 2 次幂的除法并非绝对安全的。咱们能够在保证非负数的状况下进行替换。
Java 字节码并无无符号数,但你可使用有符号数来模拟。Java 提供了静态方法能够将有符号数转化为无符号数。Kotlin 提供了无符号类型 UInt
,它提供了同样的功能,但和 Java 不同的是,它独立抽象为一个数据类型。能够想象到的是,二次幂的除法确定能够用右移操做重写。
使用 Kotlin 来演示下面两种状况。
fun javaLike(value: Int) = Integer.divideUnsigned(value, 2)
fun kotlinLike(value: UInt) = value / 2U
复制代码
经过 kotlinc
编译(Kotlin 1.4-M1) :
$ kotlinc Example.kt $ javap -c ExampleKt Compiled from "Example.kt" public final class ExampleKt { public static final int javaLike(int); Code: 0: iload_0 1: iconst_2 2: invokestatic #12 // Method java/lang/Integer.divideUnsigned:(II)I 5: ireturn 复制代码public static final int kotlinLike-WZ4Q5Ns(int); Code: 0: iload_0 1: istore_1 2: iconst_2 3: istore_2 4: iconst_0 5: istore_3 6: iload_1 7: iload_2 8: invokestatic #20 // Method kotlin/UnsignedKt."uintDivide-J1ME1BU":(II)I 11: ireturn } 复制代码
Kotlin 没有识别到这是一个二次幂的除法,它原本能够用 iushr
移位操做来代替。我向 Jetbrain 也提交过这个 issue 。
使用 -Xuse-i
也不会带来任何改变(除了移除了一些 load/store)。可是,面向 Java8 就不同了。
$ kotlinc -jvm-target 1.8 Example.kt $ javap -c ExampleKt Compiled from "Example.kt" public final class ExampleKt { public static final int javaLike(int); Code: 0: iload_0 1: iconst_2 2: invokestatic #12 // Method java/lang/Integer.divideUnsigned:(II)I 5: ireturn 复制代码public static final int kotlinLike-WZ4Q5Ns(int); Code: 0: iload_0 1: iconst_2 2: invokestatic #12 // Method java/lang/Integer.divideUnsigned:(II)I 5: ireturn } 复制代码
Integer.divideUnsigned
方法从 Java 8 开始可用。因为这样让两个函数体彻底相同了,仍是回到旧版原本进行对比。
接下来是 R8。与上面明显不一样的是,咱们使用 Kotlin 标准库做为输入,还指定了最低 api ,--min-api 24
。由于 Integer.divideUnsigned
仅在 API 24 及之后可用。
$ java -jar $R8_HOME/build/libs/r8.jar \ --lib $ANDROID_HOME/platforms/android-29/android.jar \ --min-api 24 \ --release \ --pg-conf rules.txt \ --output . \ ExampleKt.class kotlin-stdlib.jar $ dexdump -d classes.dex Opened 'classes.dex', DEX version '039' Class #0 - Class descriptor : 'LExampleKt;' Access flags : 0x0011 (PUBLIC FINAL) Superclass : 'Ljava/lang/Object;' Direct methods - #0 : (in LExampleKt;) name : 'javaLike' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 0000f8: |[0000f8] ExampleKt.javaLike:(I)I 000108: 1220 |0000: const/4 v0, #int 2 // #2 00010a: 7120 0200 0100 |0001: invoke-static {v1, v0}, Ljava/lang/Integer;.divideUnsigned:(II)I // method@0002 000110: 0a01 |0004: move-result v1 000112: 0f01 |0005: return v1复制代码#1 : (in LExampleKt;) name : 'kotlinLike-WZ4Q5Ns' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码000114: |[000114] ExampleKt.kotlinLike-WZ4Q5Ns:(I)I 000124: 8160 |0000: int-to-long v0, v6 000126: 1802 ffff ffff 0000 0000 |0001: const-wide v2, #double 0.000000 // #00000000ffffffff 000130: c020 |0006: and-long/2addr v0, v2 000132: 1226 |0007: const/4 v6, #int 2 // #2 000134: 8164 |0008: int-to-long v4, v6 000136: c042 |0009: and-long/2addr v2, v4 000138: be20 |000a: div-long/2addr v0, v2 00013a: 8406 |000b: long-to-int v6, v0 00013c: 0f06 |000c: return v6 复制代码#1 : (in LExampleKt;) name : 'kotlinLike-WZ4Q5Ns' type : '(I)I' access : 0x0019 (PUBLIC STATIC FINAL) code - 复制代码
Kotlin 有本身的无符号整数的实现,并直接内联到了函数体内。它是这样实现的,将参数和字面量转化为 long ,进行 long 的除法,最后转换为 int 。When we eventually run them through ART they’re just translated to equivalent x86 so we’re going to leave this function behind. (这句没太懂)
。这里已经错失了优化机会。
对于 Java 版本,R8 也没有使用移位运算来代替 divideUnsigned
。我已经提交 issue 来持续进行追踪。
最后的优化机会就是 ART 。
$ adb push classes.dex /sdcard/classes.dex
$ adb shell
generic_x86:/ $ sugenzong
generic_x86:/ # dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
generic_x86:/ # oatdump --oat-file=/sdcard/classes.oat
OatDexFile:
0: LExampleKt; (offset=0x000003c0) (type_idx=1) (Initialized) (OatClassAllCompiled)
0: int ExampleKt.javaLike(int) (dex_method_idx=0)
CODE: (code_offset=0x00001010 size_offset=0x0000100c size=63)...
0x00001010: 85842400E0FFFF test eax, [esp + -8192]
StackMap[0] (native_pc=0x1017, dex_pc=0x0, register_mask=0x0, stack_mask=0b)
0x00001017: 55 push ebp
0x00001018: 83EC18 sub esp, 24
0x0000101b: 890424 mov [esp], eax
0x0000101e: 6466833D0000000000 cmpw fs:[0x0], 0 ; state_and_flags
0x00001027: 0F8519000000 jnz/ne +25 (0x00001046)
0x0000102d: E800000000 call +0 (0x00001032)
0x00001032: 5D pop ebp
0x00001033: BA02000000 mov edx, 2
0x00001038: 8B85CE0F0000 mov eax, [ebp + 4046]
0x0000103e: FF5018 call [eax + 24]
StackMap[1] (native_pc=0x1041, dex_pc=0x1, register_mask=0x0, stack_mask=0b)
0x00001041: 83C418 add esp, 24
0x00001044: 5D pop ebp
0x00001045: C3 ret
0x00001046: 64FF15E0020000 call fs:[0x2e0] ; pTestSuspend
StackMap[2] (native_pc=0x104d, dex_pc=0x0, register_mask=0x0, stack_mask=0b)
0x0000104d: EBDE jmp -34 (0x0000102d)
1: int ExampleKt.kotlinLike-WZ4Q5Ns(int) (dex_method_idx=1)
CODE: (code_offset=0x00001060 size_offset=0x0000105c size=67)...
⋮
复制代码
ART 并无内联调用 divideUnsigned
,取而代之的是常规的方法调用。我提交了这个 issue 进行跟踪。
真是一段漫长的旅程,恭喜你已经完成了(或者只是翻到了文章底部)。让咱们总结一下。
经过这些事实,你能够回答文章开头的问题了。
在 Android 上,选择 除以2 仍是 右移1 ?
都不是!仅在实际须要按位操做时使用移位运算,其余数学运算使用乘除法。我将着手将 AndroidX collection 的位运算切换到乘除法。下次见!
最近可能译文会比较多,遇到一些好的文章老是忍不住要分享给你们。
其实译文并不比原创文轻松,我至少都是在通读一遍,精读两遍的基础下,才会下笔写译文。若是以为文章不错,尽情的在看,转发,分享吧!
本文使用 mdnice 排版