Android Vitals - Profiling App Startup 🔬

pyricau

Py ⚔

Posted on November 23, 2020

Android Vitals - Profiling App Startup 🔬

Header image: The In-Between by Romain Guy.

My previous articles focused on measuring Android app start in production. Once we've established a metric and scenarios that trigger a slow app start, the next step is to improve performance.

To understand what makes the app start slow, we need to profile it. Android Studio provides several types of profiler recording configurations:

  • Trace System Calls (aka systrace, perfetto): Low impact on runtime, great for understanding how the app interacts with the system and CPUs, but not the Java method calls that happen inside the app VM.
  • Sample C/C++ Functions (aka Simpleperf): Not interesting to me, the apps I deal with run much more bytecode than native code. On Q+ this is supposed to now also sample Java stacks in a low overhead way, but I haven't managed to get that working.
  • Trace Java Methods: This captures all VM method calls which introduces so much overhead that the results don't mean much.
  • Sample Java Methods: Less overhead than tracing but shows the Java method calls that happen inside the VM. This is my preferred option when profiling app startup.

trace from sampled java methods

Start recording on app startup

The Android Studio profiler has UI to start a trace by connecting to an already running process, but no obvious way to start recording on app startup.

recording a trace

The option exist but is hidden away in the run configuration for your app: check Start this recording on startup in the profiling tab.

run configuration

Then deploy the app via Run > Profile app.

profile app

Profiling release builds

Android developers typically use a debug build type for their everyday work, and debug builds often include a debug drawer, extra libraries such as LeakCanary, etc. Developers should profile release builds rather than debug builds to make sure they're fixing the actual issues that their customers are facing.

Unfortunately, release builds are non debuggable so the Android profiler can't record traces on release builds.

Here are a few options to work around that issue.

1. Create a debuggable release build

We could temporarily make our release build debuggable, or create a new release build type just for profiling.

android {
  buildTypes {
    release {
      debuggable true
      // ...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Libraries and Android Framework code often have a different behavior if the APK is debuggable. ART disables a lot of optimizations to enable connecting a debugger, which affects performance significantly and unpredictably (see this talk. So this solution is not ideal.

2. Profile on a rooted device

Rooted devices allow the Android Studio profiler to record traces on non debuggable builds.

Profiling on an emulator is generally not recommended - the performance of every system component will be different (cpu speed, cache sizes, disk perf), so an 'optimization' can actually make things slower by shifting the work to something that's slower on a phone. If you don't have a rooted physical device available, you can create an emulator without Play Services and then run adb root.

3. Use simpleperf on Android Q

There's a tool called simpleperf which supposedly enables profiling release builds on non rooted Q+ devices, if they have a special manifest flag. The doc calls it profileableFromShell, the XML example has a profileable tag with an android:shell attribute, and the official manifest documentation shows nothing.

<manifest ...>
    <application ...>
      <profileable android:shell="true" />
    </application>
</manifest>
Enter fullscreen mode Exit fullscreen mode

I looked at the manifest parsing code on cs.android.com:

if (tagName.equals("profileable")) {
  sa = res.obtainAttributes(
    parser,
    R.styleable.AndroidManifestProfileable
  );
  if (sa.getBoolean(
    R.styleable.AndroidManifestProfileable_shell,
    false
  )) {
    ai.privateFlags |= ApplicationInfo.PRIVATE_FLAG_PROFILEABLE_BY_SHELL;
  }
}
Enter fullscreen mode Exit fullscreen mode

It looks like you can trigger profiling from command line if the manifest has <profileable android:shell="true" /> (I haven't tried). As far as I understand the Android Studio team is still working on integrating with this new capability.

Profiling a downloaded APK

At Square our releases are built in CI. As we saw earlier, profiling app startup from Android Studio requires checking an option in a run configuration. How can we do this with a downloaded APK?

Turns out, it's possible but hidden under File > Profile or Debug APK. This opens up a new window with the unzipped APK, and from that you can set up the run configuration and start profiling.

profile APK

Android Studio profiler slows everything down

Unfortunately, when I tested these methods on a production app, profiling from Android Studio slowed down app startup a lot (~10x slower), even on recent Android versions. I'm not sure why, maybe it's the "advanced profiling", which doesn't seem like it can be disabled. We need to find another way!

Profiling from code

Instead of profiling from Android Studio, we can start the trace directly from code:

val tracesDirPath = TODO("path for trace directory")
val fileNameFormat = SimpleDateFormat(
  "yyyy-MM-dd_HH-mm-ss_SSS'.trace'",
  Locale.US
)
val fileName = fileNameFormat.format(Date())
val traceFilePath = tracesDirPath + fileName
// Save up to 50Mb data.
val maxBufferSize = 50 * 1000 * 1000
// Sample every 1000 microsecond (1ms)
val samplingIntervalUs = 1000
Debug.startMethodTracingSampling(
  traceFilePath, 
  maxBufferSize,
  samplingIntervalUs
)

// ...

Debug.stopMethodTracing()
Enter fullscreen mode Exit fullscreen mode

We can then pull the trace file from the device and load it in Android Studio.

When to start recording

We should start recording the trace as early as possible in the app lifecycle. As I explained in Android Vitals - Diving into cold start waters 🥶, the earliest code that can run on app startup before Android P is a ContentProvider and on Android P+ it's the AppComponentFactory.

Before Android P / API < 28

class AppStartListener : ContentProvider() {
  override fun onCreate(): Boolean {
    Debug.startMethodTracingSampling(...)
    return false
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode
<?xml version="1.0" encoding="utf-8"?>
<manifest
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools">

  <application>
    <provider
        android:name=".AppStartListener"
        android:authorities="com.example.appstartlistener"
        android:exported="false" />
  </application>

</manifest>
Enter fullscreen mode Exit fullscreen mode

Unfortunately we cannot control the order in which ContentProvider instances are created, so we may be missing some of the early startup code.

Edit: @anjalsaneen pointed out in the comments that when defining a provider we can set a initOrder tag, and the highest number gets initialized first.

Android P+ / API 28+

@RequiresApi(28)
class MyAppComponentFactory() :
  androidx.core.app.AppComponentFactory() {

  @RequiresApi(29)
  override fun instantiateClassLoader(
    cl: ClassLoader,
    aInfo: ApplicationInfo
  ): ClassLoader {
    if  (Build.VERSION.SDK_INT >= 29) {
      Debug.startMethodTracingSampling(...)
    }
    return super.instantiateClassLoader(cl, aInfo)
  }

  override fun instantiateApplicationCompat(
    cl: ClassLoader,
    className: String
  ): Application {
    if  (Build.VERSION.SDK_INT < 29) {
      Debug.startMethodTracingSampling(...)
    }
    return super.instantiateApplicationCompat(cl, className)
  }
}
Enter fullscreen mode Exit fullscreen mode
<?xml version="1.0" encoding="utf-8"?>
<manifest
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools">

  <application
    android:appComponentFactory=".MyAppComponentFactory"
    tools:replace="android:appComponentFactory"
    tools:targetApi="p">
  </application>

</manifest>
Enter fullscreen mode Exit fullscreen mode

Where to store traces

val tracesDirPath = TODO("path for trace directory")
Enter fullscreen mode Exit fullscreen mode

When to stop recording

In Android Vitals - First draw time 👩‍🎨, we learnt that cold start ends when the app's first frame completely loads. We can leverage the code from that article to know when to stop method tracing:

class MyApp : Application() {

  override fun onCreate() {
    super.onCreate()

    var firstDraw = false
    val handler = Handler()

    registerActivityLifecycleCallbacks(
      object : ActivityLifecycleCallbacks {
      override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
      ) {
        if (firstDraw) return
        val window = activity.window
        window.onDecorViewReady {
          window.decorView.onNextDraw {
            if (firstDraw) return
            firstDraw = true
            handler.postAtFrontOfQueue {
              Debug.stopMethodTracing()
            }
          }
        }
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

We could also record for a fixed amount of time greater than the app start, e.g. 5 seconds:

Handler(Looper.getMainLooper()).postDelayed({
  Debug.stopMethodTracing()
}, 5000)
Enter fullscreen mode Exit fullscreen mode

Profiling with Nanoscope

Another option for profiling app startup is uber/nanoscope. It's an Android image with built-in low overhead tracing. It's great but has a few limitations:

  • It only traces the main thread.
  • A large app will overfill the in-memory trace buffer.

App Startup steps

Once we have a startup trace, we can start investigating what's taking time. You should expect 3 main sections:

  1. ActivityThread.handlingBindApplication() contains the startup work before activity creation. If that's slow then we probably need to optimize Application.onCreate().
  2. TransactionExecutor.execute() is in charge of creating and resuming the activity, which includes inflating the view hierarchy.
  3. ViewRootImpl.performTraversals() is where the framework performs the first measure, layout and draw. If this is slow then it could be the view hierarchy being too complex, or views with custom drawing that need to be optimized.

startup steps

If you notice that a service is being started before the first view traversal, it might be worth delaying the start of that service so that it happens after the view traversal.

Conclusion

A few take aways:

  • Profile release builds to focus on the actual issues.
  • The state of profiling app start on Android is far from ideal. There's basically no good out of the box solution, but the Jetpack Benchmark team is working on this.
  • Start the recording from code to prevent Android Studio from slowing everything down.

Many thanks to the many folks who helped me out on Slack and Twitter: Kurt Nelson, Justin Wong, Leland Takamine, Yacine Rezgui, Raditya Gumay, Chris Craik, Mike Nakhimovich, Artem Chubaryan, Rahul Ravikumar, Yacine Rezgui, Eugen Pechanec, Louis CAD, Max Kovalev.

💖 💪 🙅 🚩
pyricau
Py ⚔

Posted on November 23, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related