Mitigating soft verification issues in R8 and D8

Morten Krogh-Jespersen
Android Developers
11 min readMar 20, 2023

--

ART (Android Runtime) and Dalvik (devices before Android 5) are managed runtime environments that execute the DEX code of an application on Android devices. Executing code in managed environments requires that the code has been verified. Verifying code can be delayed until runtime just before loading a class, which can have a negative impact on runtime performance.

This blog post sheds light on this issue and why you can safely ignore it going forward when using R8 and D8, meanwhile enjoying that your app probably runs a bit faster on older devices. Our measurements show an improvement between up to 22 % on a Nexus 5X device running Api 23 on a real world app.

Why DEX code has to be verified

Managed runtime environments in this context means that memory is managed/abstracted such that objects can be created and garbage collected dynamically, without the developer worrying about allocation and deallocation. Managed also means direct indexing into memory is prohibited unlike native programs that run in the context of the operating system.

To ensure that applications behave correctly and adhere to the requirements of an ART/Dalvik VM, a verifier scans the code to ensure that programs are valid. Here are two examples of problems the verifier might find:

1. an instruction attempts to load register values outside the scope of its method

2. the code attempts to create a new instance of a class that does not exist

The former (1) is an example of a hard verification error, and the latter (2) is an example of a soft verification issue.

Before diving into how D8 and R8 mitigates soft verification issues we should clearly describe the process of verification on device.

Verifying DEX code and soft verification errors

The DEX files that ship with an application will always be subject to verification before the VM loads them. This happens automatically when the application is installed or the first time it runs on the device. Normally the verification process happens indirectly through dex2oat — a program on the device that transforms DEX files into OAT files. You can read more at Configuring ART | Android Open Source Project

The verifier traverses code bundled in the application. If a hard verification error occurs the execution of the app is halted and output is pasted to logcat:

W/dalvikvm: VFY: bad arg 1 (into Lcom/example/MainActivity;)
W/dalvikvm: VFY: rejecting call to Lcom/example/MainActivity;foo(Lcom/example/SomeClass;)V
W/dalvikvm: VFY: rejecting opcode 0x70 at 0x0004

The error could for example be an invoke-virtual to a static method. If a class has no errors it will be marked as verified in the OAT file and the VM knows that it can load it directly. However, what happens when the verifier cannot look up the targeted class to see if a method is virtual or static?

The ART and Dalvik VM’s are class-path extendable meaning that the definition of classes can be given at runtime. As a result, the verifier cannot give a hard verification error based on missing references because the references could be present when needed. The verifier therefore delays the verification of a class with missing references until runtime — that is, just before the class is loaded by the VM — to see if it can pass verification at that point in time. To indicate to the VM that this class needs to be runtime-verified it marks the class file to have status RetryVerificationAtRuntime in the OAT file.

At this point you may be wondering why there are references to non-existing classes and methods. The reason is that with every new Android version there are new APIs added. Older VMs will not have these definitions on their boot classpath since they were introduced at a later time.

You can check your own application for soft-verification issues by pushing the APK to a device (or an emulator) with an old API level (for example API level 23) and run dex2oat with -verbose:verifier option set:

dex2oat - dex-file=/data/app/com.google.firebase.quickstart.fcm-1/base.apk - oat-file=/data/local/tmp/foo.oat - runtime-arg -verbose:verifier

All the soft verification issues are of the form:

01–03 01:49:51.895 19821 19827 I dex2oat : Soft verification failures in void com.example.MainActivity.onCreate(android.os.Bundle)

You will probably be surprised how many soft verification issues appear. The verification runs on a class-by-class basis and if a single method or field fails verification with a soft verification error the entire class is marked for verification at runtime.

A working example

A piece of code is worth a thousands words so let’s see an example of a soft verification issue arising from a method using a guarded API call taken from the Firebase Cloud Messaging Quickstart project:

class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create channel to show notifications.
val channelId = getString(R.string.default_notification_channel_id)
val channelName = getString(R.string.default_notification_channel_name)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager?.createNotificationChannel(NotificationChannel(channelId,
channelName, NotificationManager.IMPORTANCE_LOW))
}

...
}
}

In the onCreate function there is a conditional check on the SDK_INT guarding that we do not try to construct a new notification channel object and invoke createNotificationChannel on the new instance of NotificationManager since these APIs were introduced in API 26 (NotificationManager | Android Developers).

Installing and executing the demo project on an older device will show a warning:

dex2oat --dex-file=/data/app/com.google.firebase.quickstart.fcm-1/base.apk --oat-file=/data/local/tmp/foo.oat --runtime-arg -verbose:verifier 

01-03 01:49:51.895 19821 19827 I dex2oat : Soft verification failures in void com.google.firebase.quickstart.fcm.kotlin.MainActivity.onCreate(android.os.Bundle)
01-03 01:49:51.895 19821 19827 I dex2oat : void com.google.firebase.quickstart.fcm.kotlin.MainActivity.onCreate(android.os.Bundle): [0x47] couldn't find method android.app.NotificationManager.createNotificationChannel (Landroid/app/NotificationChannel;)V

The result is therefore that MainActivity on devices before API level 26 will delay verification of the class until it is loaded. At that point it will fail to verify so executing the code will be done by interpreting the code. Trying to verify and then executing the code by interpreting it will slow down performance.

Automatic outlining of code in D8 and R8

Armed with the knowledge above we can mitigate the performance penalty on older runtimes by moving all instructions targeting higher API levels into other classes. This process is known as outlining (or out-of-line) and this approach has been done manually in both AndroidX and Chromium — see Class Verification Failures. The approach is extremely simple and we basically just create a static forwarding method into the library taking the potential receiver and arguments and returning the returned value from the library call.

Outlining the problematic instructions allows verification and optimization of the classes that previously would have failed verification — by transferring the soft verification issue to the outlined code. If the outlined code is loaded by the VM at runtime, the penalty of verification will still occur. Outlining will therefore not remove the verification error, just move it a layer out. Because the outline is only invoked if the API is present (depending on the check for SDK_INT being correct), the penalty will never occur on older VMs because the class containing the outline will only be loaded when the target of the reference is present.

The code in the previous section will generate two outlines. One for creating a new instance of the NotificationChannel, and one for calling createNotificationChannel on the notificationManager:

    .line 44
invoke-static {v0, v1, v3}, Landroidx/core/app/NotificationCompat$$ExternalSyntheticApiModelOutline0;->m(Ljava/lang/String;Ljava/lang/CharSequence;I)Landroid/app/NotificationChannel;

move-result-object v0

invoke-static {v2, v0}, Landroidx/core/app/NotificationCompat$$ExternalSyntheticApiModelOutline1;->m(Landroid/app/NotificationManager;Landroid/app/NotificationChannel;)V

As a consequence, running dex2oat again on the code will show that we have indeed moved the soft verification errors to the outlines.

01-03 01:56:33.897 20311 20314 I dex2oat : Soft verification failures in void androidx.core.app.NotificationCompat$$ExternalSyntheticApiModelOutline0.m(java.lang.String, java.lang.CharSequence, int)
01-03 01:56:33.897 20311 20314 I dex2oat : void androidx.core.app.NotificationCompat$$ExternalSyntheticApiModelOutline1.m(android.app.NotificationManager, android.app.NotificationChannel): [0x0] couldn't find method android.app.NotificationManager.createNotificationChannel (Landroid/app/NotificationChannel;)V

Fixing exception handlers and missing super-types

Outlining, as done above, will move a lot of soft verification issues to non-visited code at runtime but there are cases where outlining is not sufficient to mitigate soft- and hard verification issues. Using unknown classes in exception handlers will cause a hard verification error. Consider the following code:

try {
callPrivateMethod()
tryLogin()
} catch (android.app.AuthenticationRequiredException e) {
// handle error
}

The exception class android.app.AuthenticationRequiredException was introduced at API level 26 and loading this class on an API level 25 or less device will cause a hard verification error. Mitigating by outlining would require outlining the entire try-catch block which could be impossible if the code invokes any private methods.

To work around this issue one would have to do the following:

try {
callPrivateMethod()
tryLogin()
} catch (Exception e) {
If (e.getClass().getName().equals("android.app.AuthenticationRequiredException)") {
// handle error
}
}

This is annoying to remember and cumbersome to write. R8 and D8 fixes this by creating small classes with the same name for exceptions introduced after the min-API level. The class is similar to the source code below:

package android.app;

public class AuthenticationRequiredException extends Exception {

static {
throw new NoClassDefFoundError();
}
}

We call such a class a stub since it is not doing anything. Using this trick will enable developers to write idiomatic try-catch code. On devices where the class is present, the class definition on the bootclasspath will take precedence and be loaded. On older devices the presence of the stub will allow verification to succeed. Accessing the stubbed class will throw an error on initialization. R8 and D8 will also stub library super-types of program classes if introduced on later api levels than min-API.

One caveat with this approach is that direct lookup on classes for determining API level could now cause exceptions changing from java.lang.ClassNotFoundException to java.lang.NoClassDefFoundError. However, we believe that the gains in runtime performance far outweigh the negative consequences when considering it is advised against using exceptions to guide API level control flow, and the unreliability of the exception type that is thrown when loading these classes on different Android VMs.

A note on api-modeling in R8

R8 is an optimizing compiler and to reduce the size of the code R8 will both do inlining and class merging. If R8 didn’t track API levels it could cause soft verification issues that were manually outlined to be inlined again. R8 started assigning API levels to methods — to prevent moving soft verification issues into code that would be executed at runtime — already in Android Studio Chipmunk (R8 version 3.3) to ensure better performance stability. You can read more about R8 at Shrink, obfuscate, and optimize your app | Android Developers.

Experiments on older API devices

We’ve tested api-modeling internally as well but for this blog post we’ve used an open source and a demo project to show the performance improvements (and lack of performance regression).

No changes were made to the source code of the app except setting up the project according to their individual README’s. The messaging app was amended with the firebase json configuration and tivi was amended with trakt.tv and TMDb keys.

Since api-modeling is baked into AGP we have to manually enable/disable api-modeling to get reliable results. To that end we built two version of our compiler based on version 8.0.19-dev:

Each of the above versions can be manually added to your project by modifying the build.gradle file:

pluginManagement {
buildscript {
repositories {
mavenCentral()
maven {
url = uri("https://storage.googleapis.com/r8-releases/raw/main")
}
}
dependencies {
classpath("com.android.tools:r8:<hash-from-above>")
}
}
}

The versions above should probably only be used for benchmarking since these are canary versions and not official builds.

We then created the following apks for each project:

  • D8 with `Force enable api-modeling for benchmark tests`
  • D8 with `Disable api-modeling for benchmark tests`
  • R8 with `Force enable api-modeling for benchmark tests`
  • R8 with `Disable api-modeling for benchmark tests`

We ran the apks on a Nexus 5X (api level 23) and Pixel 2 device (api level 26) and measured until the ActivityManager reported the main activity is displayed:

03-07 08:57:18.864  1184  1230 I ActivityManager: Displayed app.tivi/.home.MainActivity: +281ms

We ran the experiments five times with a cool down of 30 seconds from install time to execution time to ensure the device would not overheat during the experiments.

The average results are in the tables below and all data can be found here:

Startup times for Firebase Cloud Messaging Quickstart
Startup times for Tivi

The results clearly show that enabling api outlining and stubbing has a significant performance improvement but the improvement is individual to each app depending on the use of new apis. For Tivi, using a D8 with api modeling has an improvement on an api level 23 device with 15 %. For R8 the improvement is even bigger percentage-wise when compared to no api modeling at all. When moving to api level 26 we see the improvements deteriorate for D8 which is expected since the amount of “new apis” are less from an api level 26 standpoint.

Another interesting point is that using R8 for compiling your app also gives a nice improvement on the startup performance:

R8 vs D8 startup comparison

So if you really care about startup performance you should really consider using R8.

Api modeling will cause an increase in code size since D8 and R8 compared to no modeling. The table below shows the size regressions in KB:

Changes to size with api-modeling

The increase is around 18 KB for D8 with modeling and around 10 KB for R8 with modeling. For both R8 and D8 the increase is less than 0.3 %.

How to get started with using Api modeling in R8 and R8

R8 has had api-modeling enabled for disallowing inlining and merging from Android Studio Electric Eel (AGP 7.4 with R8 version 4.0) and enabled outlining and stubbing in Android Studio Flamingo (AGP 8.0 with R8 version 8.0).

D8 will have api-modeling with outlining and stubbing enabled from Android Studio Giraffe Beta (AGP 8.1 with D8 version 8.1).

It will not be possible to turn off api modeling in D8 and R8 going forward. For most applications it will provide a nice runtime performance on older devices and next to no penalty on newer devices. Additionally, now that outlining has direct compiler support it allows AndroidX and other library developers to write more idiomatic code — and not use resources on manual outlining.

If you find errors or see significant runtime performance regressions we would be happy to hear about them by creating an issue on our issue tracker.

--

--