Profile & Benchmark Android Builds
Build analyzing tools and different benchmark scenarios
In my previous article I covered various techniques to optimize Android build times. If you haven’t read it yet, I highly recommend checking it out:
Understanding your build performance is crucial — not just to identify bottlenecks but also to measure the impact of your optimizations. I’ll walk you through the tools and techniques to analyze your Gradle builds and track improvements over time.
To analyze Gradle builds, we can use:
- Android Studio Build Analyzer — A built-in tool to identify bottlenecks in your build process.
./gradlew --profile
– Generates a simple profile report to analyze task execution times../gradlew --scan
– Provides a detailed report with insights into build performance (Uploads remotely)gradle-profiler --benchmark
– Tool for benchmarking Gradle builds over multiple runs.
#1 Android Studio Build Analyzer:
We’ve all noticed the Build Analyzer tab next to the Build window. After a successful build, it provides key insights such as total build duration, configuration time, and optimization suggestions.
By clicking on “Tasks impacting build duration”, you can dive deeper into each task’s execution time, helping you pinpoint slow or inefficient steps in your build process.
#2 Gradle Profile Option (Local)
The Gradle Profile option generates an HTML report that provides detailed information about task execution, configuration performance, dependency resolution, etc.
To generate the report, run the following command in the terminal:
./gradlew --profile assembleDebug # Or assemble[Flavor]Debug
#3. Gradle Scan Option (Remote 🌐)
The Gradle Build Scan provides an even more detailed performance report compared to local profiling. In addition to task execution and configuration times, it also includes: Build cache usage, Network activity,
Test performance, Dependency insights and many more useful things.
To generate a build scan, run the following command in the terminal and accept the terms:
./gradlew --scan
Publishing a build scan to scans.gradle.com requires accepting the Gradle Terms of Use defined at https://gradle.com/help/legal-terms-of-use.
Do you accept these terms? [yes, no] yes
Gradle Terms of Use accepted.
Publishing build scan...
https://gradle.com/s/dtt4r5mtn655y
📌 Note: This report is public by default, meaning anyone with the link can access it. However, you can delete it if needed.
#4 Gradle Profiler tool
The Gradle Profiler is a powerful tool for benchmarking build performance across different scenarios(with scenarios.txt file). It helps analyze how changes impact build times, allowing you to compare:
- Different JVM/Gradle arguments
- Code change time on specific files/modules
- Resource change
- Layout/Composable change
- Comparing the performance of Different git branches
— To install Gradle Profiler, you can use Homebrew (or other installation methods):
brew install gradle-profiler
— Once installed, you can run it with the --benchmark
option along with a scenarios.txt file:
gradle-profiler --benchmark \
--project-dir [root-dir] \
--output-dir [output-dir] \
--scenario-file scenarios.txt
📌 Note: Gradle Profiler uses the same Gradle version as your project but may add temporary files during execution. To avoid it, I recommend using the
/build
directory for both--gradle-user-home
and--output-dir
.
scenarios.txt
defines multiple benchmarking configurations in JSON-like syntax.
Here are some options that I found useful:
scenario_name {
tasks = [":app:assembleDebug"] // gradle task for scenario
cleanup-tasks = ["clean"] // Cleanup gradle task
jvm-args = ["-Xmx4g", "-XX:+UseParallelGC"] // Java arguments
gradle-args = ["--max-workers=4"] // Gradle arguments
// Adds a public method to a Java or Kotlin source class. Each iteration adds a new method and removes the previously added one
apply-non-abi-change-to = ["path/your_code_file.java"]
// Changes the body of a public method in a Java or Kotlin source class
apply-abi-change-to = ["path/your_code_file.java"]
// Adds view to layout
apply-android-layout-change-to = "app/src/main/res/your_layout_file.xml"
// Adds composable
apply-kotlin-composable-change-to = "app/src/main/java/your_composable.kt"
// Changes value of string
apply-android-resource-value-change-to = "app/src/main/res/values/strings.xml"
// Adds new string
apply-android-resource-change-to = "src/main/res/values/strings.xml"
// checks out a specific commit for the build step, and a different one for the cleanup step.
git-checkout = {
cleanup = "efb43a1" #commit hash or branch name"
build = "master”
}
// Reverts a given set of commits before the build and resets it afterward.
git-revert = [“eaav2e4”]
}
You can check more options in docs.
— I’ve created a sample project on GitHub with multiple modules containing dummy classes. You can explore different branches to experiment with various performance scenarios:
Now, let’s go through some of the scenarios. 🚀
— Testing Different Gradle & JVM Arguments
scenarios:
1️⃣ clean_build_parallelGC_4gb
– Uses Parallel GC with 4GB of heap memory.
2️⃣ clean_build_G1GC_2gb_max_workers_4
– Uses G1 GC, limits heap to 2GB, and restricts Gradle to 4 workers.
clean_build_parallelGC_4gb {
tasks = [":app:assembleDebug"]
jvm-args = ["-Xmx4g", "-XX:+UseParallelGC"]
cleanup-tasks = ["clean"]
}
clean_build_G1GC_2gb_max_workers_4 {
tasks = [":app:assembleDebug"]
gradle-args = ["--max-workers=4"]
jvm-args = ["-XX:+UseG1GC"]
cleanup-tasks = ["clean"]
}
As a result, we can see the build iterations with Historical or Sorted(clearer) view and check the difference.
parallelGC_4gb vs G1GC_2gb_max_workers_4
scenarios:
1️⃣ non_parallel
→ standard run
2️⃣ parallel
→ Enabling gradle parallel execution via gradle param
# <root-project>/scenarios.txt
non_parallel {
tasks = [":app:assembleDebug"]
clear-build-cache-before = SCENARIO
}
parallel {
tasks = [":app:assembleDebug"]
gradle-args = ["--parallel"]
clear-build-cache-before = SCENARIO
}
— Enabling Caching for Faster Builds
scenarios:
1️⃣ with_caching
– Uses Build Cache and Configuration Cache.
2️⃣ without_caching
– Runs a regular build without caching.
To simulate real-world changes, we modify dummy files in different modules using apply-non-abi-change-to
. We also clear the build cache before each scenario starts, using clear-build-cache-before = SCENARIO
.
# <root-project>/scenarios.txt
with_caching {
tasks = [":app:assembleDebug"]
gradle-args = ["--configuration-cache", "--build-cache"]
apply-non-abi-change-to = ["feature1/src/main/java/ge/chapo/feature1/DummyData.kt",
"feature2/src/main/java/ge/chapo/feature2/DummyData.kt",
"feature3/src/main/java/ge/chapo/feature3/DummyData.kt",
]
clear-build-cache-before = SCENARIO
}
without_caching {
tasks = [":app:assembleDebug"]
apply-non-abi-change-to = ["feature1/src/main/java/ge/chapo/feature1/DummyData.kt",
"feature2/src/main/java/ge/chapo/feature2/DummyData.kt",
"feature3/src/main/java/ge/chapo/feature3/DummyData.kt",
]
clear-build-cache-before = SCENARIO
}
we can use--measure-config-time
and --measure-local-build-cache
options to get additional info
gradle-profiler --benchmark --measure-config-time --measure-local-build-cache --project-dir ./ --output-dir ./build --scenario-file scenarios.txt --gradle-user-home=./build/gradle
As a result, we can see that with Caching, it is faster. You can also check how the faster configuration phase is on task start row and compare the cache size on the local build cache size row.
— Changing files
scenarios:
1️⃣ modify_functions
→ Changes function implementations (ABI & Non-ABI changes).
2️⃣ modify_strings
→ Modifies a string resource in strings.xml
.
3️⃣ modify_layout
→ Updates a layout XML file.
4️⃣ modify_compose
→ Alters a Jetpack Compose UI component.
by default warm-ups = 6 and iterations = 10, we can change it in our scenarios
# <root-project>/scenarios.txt
modify_functions {
tasks = [":app:assembleDebug"]
apply-abi-change-to = ["feature1/src/main/java/ge/chapo/feature1/DummyData.kt"]
apply-non-abi-change-to = ["feature1/src/main/java/ge/chapo/feature1/DummyData.kt"]
warm-ups = 1
iterations = 3
clear-build-cache-before = SCENARIO
}
modify_strings {
tasks = [":app:assembleDebug"]
apply-android-resource-change-to = ["feature1/src/main/res/values/strings.xml"]
warm-ups = 1
iterations = 3
clear-build-cache-before = SCENARIO
}
modify_layout {
tasks = [":app:assembleDebug"]
apply-android-layout-change-to = ["feature1/src/main/res/layout/some_layout.xml"]
warm-ups = 1
iterations = 3
clear-build-cache-before = SCENARIO
}
modify_compose {
tasks = [":app:assembleDebug"]
apply-kotlin-composable-change-to = ["app/src/main/java/ge/chapo/gradleoptimisationsample/MainActivity.kt"]
warm-ups = 1
iterations = 3
clear-build-cache-before = SCENARIO
}
In the result, you can compare performance after changing different types of files:
— KAPT vs KSP (Git revert)
scenarios:
1️⃣ kapt
→ revert specific commit
2️⃣ ksp
→ running the current branch, which is a migration from kapt to ksp
# <root-project>/scenarios.txt
kapt {
tasks = [":app:assembleDebug"]
git-revert = ["b1b1c7c1"]
cleanup-tasks = ["clean"]
warm-ups = 2
}
ksp {
tasks = [":app:assembleDebug"]
cleanup-tasks = ["clean"]
warm-ups = 2
}
KSP wins of course 💪
— Disabling jetifier (Git checkout)
scenarios:
1️⃣ disable_jetifier
→ checks out on “master” branch where jetifier is disabled
2️⃣ enable_jetifier
→ checks out on “scenarios/7-jetifier” where jetifier is enabled
# <root-project>/scenarios.txt
disable_jetifier {
tasks = [":app:assembleDebug"]
git-checkout = {
build = "master"
}
cleanup-tasks = ["clean"]
warm-ups = 1
}
enable_jetifier {
tasks = [":app:assembleDebug"]
git-checkout = {
build = "scenarios/7-jetifier"
}
cleanup-tasks = ["clean"]
warm-ups = 1
}
I hope this guide was helpful and gave you new insights into Gradle build analysis. If you have any questions or experiences to share, feel free to drop a comment!
Happy coding! 🚀👨💻