In my project, I encountered a significant issue where it would take roughly 20 minutes to test, build, and publish the project. Since every engineer does this at least a couple of times a day, it quickly became a substantial pain point and a waste of time. Determined to resolve this, I embarked on a typical performance improvement journey, which involves extracting data to identify the bottleneck, understanding why it’s happening, addressing the issue, and then running benchmarks to gauge the improvement. Like any performance debugging task, using the right tools is crucial. In this case, I turned to Gradle Build Scan for assistance.
1. Running Analysis
The initial step in solving such issues is to gather data that will help you pinpoint the bottleneck. This could involve profiling, sampling, or data dumping. The build process with Gradle is intricate, encompassing the configuration of various plugins, scripts, dependencies, and executing tasks. Without clear visibility into which part is consuming the most time, it’s challenging to identify where improvements are needed. For Gradle, utilizing a build scan is an effective way to acquire detailed metadata about your build.
Gradle Build Scan
Gradle includes a feature known as build scan, which provides a comprehensive report of your tasks. Contrary to what the name might suggest, it works with any Gradle tasks. To enable a build scan, append the --scan
option to your Gradle command. Optionally, you can add --rerun-tasks
to prevent Gradle from using the build cache.
./gradlew build --scan --rerun-tasks
Executing this command will prompt you to agree to terms, after which your build scan result will be uploaded to scans.gradle.com.
⚠️Warning⚠️ Your build scan result URL will be publicly available.
2. Locating the Bottleneck
Now, let’s dive straight into the performance section of the build scan. The Build tab in the Performance page gives you a summary of the total build time, breaking down how much time each part took.
In this case, the total build time was 15 minutes and 3 seconds, with 2 minutes and 50 seconds spent on configuration and 12 minutes and 11 seconds on the execution of build tasks. I recommend exploring every tab of the performance page, as each offers different insights into potential problem areas.
Configuration
In the “Configuration” tab, identify which configurations consumed the most time.
Dependency resolution
The “Dependency Resolution” tab indicated that resolving dependencies was a significant time consumer during configuration. Coupled with information from the Configuration tab, it seemed likely that complex task dependencies were also contributing to the issue.
Task execution
The primary cause of the prolonged build time stemmed from task execution itself. The total time for executing all tasks was 19 minutes and 36 seconds, but actual execution time was reduced to 12 minutes and 11 seconds thanks to Gradle’s parallel processing capabilities. To delve deeper, click on the “Tasks Executed” link.
By grouping tasks by type, it became evident that test tasks were the most time-consuming. We’ve identified our bottleneck! Still, it’s prudent to review other tabs in the Performance page for a comprehensive understanding.
Build Cache
For debugging purposes, I wasn’t utilizing any cache, but it’s crucial to leverage the build cache in actual builds to prevent unnecessary task reruns.
Daemon
Reusing the Gradle daemon can also enhance build performance. The daemon tab will indicate whether the Gradle daemon is functioning correctly or even running at all.
Network activity
The Network Activity tab can help identify network issues, problems connecting to the Gradle repository, or issues with remote dependencies not being cached.
3. Finding and Fixing the Issue
Having identified that slow tests were the bottleneck, the next step was to determine why and how to rectify this. For insights into slow task execution, consult the Timeline tab.
The Timeline showed that the sluggishness was due to long-running tests (specifically, Spring Boot tests) being executed sequentially. With many Gradle workers idle for the duration of the build, it was clear we were not utilizing our CPU resources efficiently. The solution? Run tests in parallel.
Running Tests in Parallel
When using JUnit as the testing framework, there are two methods for parallel test execution: JUnit’s parallel test and Gradle’s parallel build. The former runs tests within the same module in parallel, while the latter runs tests of multiple modules in parallel. I opted for the Gradle parallel build as the fix for our problem. The reasoning behind this choice could be a topic for another post, as it falls outside the scope of this article.
4. Benchmark
Now that we’ve fixed the issue, let’s run a benchmark to check the fix in action. I’ve run test task under 4 different conditions, each using different test parallel setting.
Condition | try 1 | try 2 |
No parallel | 14m 48s | 14m 46s |
JUnit parallel (unit test only) | 14m 18s | 13m 57s |
gradle parallel only | 6m 4s | 5m 57s |
gradle + junit parallel | 8m 19s | 6m 17s |
- No Parallel Execution
- This is our baseline for comparison, with no parallelization implemented.
- JUnit Parallel (Unit Test Only)
- This was our original configuration. JUnit parallel test was enabled only on unit tests as it had problems with running Integration and Functional tests with shared mock
- A modest improvement in build times, suggesting that parallelizing unit tests provides some benefit.
- Gradle Parallel Only
- A significant reduction in build times, indicating that Gradle’s parallel execution capability is highly effective.
- Gradle + JUnit Parallel
- Despite combining both JUnit and Gradle parallelizations, there was an unexpected increase in build times, possibly due to excessive context switching.
The revised benchmarking paints a clear picture: Gradle’s parallel execution capabilities are most effective for our project, drastically reducing build times and enhancing overall efficiency. The attempt to combine Gradle and JUnit parallel executions, while theoretically sound, proved counterproductive, likely due to the increased complexity and resource contention.
5. Conclusion
The journey to optimize our project’s build process has been both enlightening and impactful. It reinforced the importance of a systematic approach to performance issues and showcased how effective tools, such as Gradle Build Scan, can significantly aid in diagnosing and resolving bottlenecks. By employing a data-driven strategy, we were able to pinpoint the specific areas that required attention, notably the task execution phase dominated by time-consuming tests.