Macrobenchmark로 프레임 성능 측정하기
문제 상황
온보딩 화면에서 기능 소개 탭을 스크롤할 때 프레임 드랍이 발생하는 문제가 있다.
이 글에서는 Macrobenchmark를 활용하여 프레임 성능을 측정하고, 성능 문제를 해결하는 연습을 해 보겠다.
Macrobenchmark
앱의 성능을 측정하는 행위를 벤치마킹이라고 한다. 정기적으로 벤치마크를 수행하면 앱의 성능 문제를 분석할 수 있고, 업데이트로 인해 앱의 성능이 하락했는지 확인할 수 있다.
Android에서는 Macrobenchmark와 Microbenchmark 두 가지의 벤치마킹 라이브러리를 제공한다.
Macrobenchmark는 앱 시작 시간이나 UI 상호작용, 애니메이션 등 큼직한 사용자 경험을 측정한다. 실제로 앱을 사용하는 것처럼 앱을 조작하며 측정할 수 있다.
Microbenchmark는 좀 더 작은 부분의 코드를 세밀하게 측정한다. CPU-intensive 코드의 성능을 측정하려면 Microbenchmark 라이브러리를 사용해야 한다. 알고리즘 문제를 풀 때 코드를 한 줄씩 디버깅하는 것과 비슷하다.
지금은 프레임 성능을 측정해야 하므로 Macrobenchmark를 사용해야 한다.
모듈 세팅
먼저 Macrobenchmark 모듈을 만들어야 한다. Android Studio에서 제공하는 템플릿을 사용하면 매우 쉽게 만들 수 있다.
왼쪽 ``Templates``에서 Benchmark를 선택하고, 모듈과 패키지 이름을 설정하면 된다.
벤치마킹 작업을 수행하려면 앱이 ``profileable``해야 한다. 다행히 템플릿을 사용하여 벤치마크 모듈을 만들면 ``AndroidManifest.xml``에 자동으로 ``profileable`` 태그가 추가된다.
<application
android:name="com.ku_stacks.ku_ring.KuRingApplication"
...
>
<profileable
android:shell="true"
tools:targetApi="29" />
// ...
</application>
벤치마크 앱을 위해 ``benchmark``라는 build variant가 새로 만들어진다. 실제 사용자와 최대한 같은 환경을 만들기 위해 proguard를 활성화하고, 앱을 non-debuggable 상태로 설정해야 한다. 단, 서명은 debug 키로 해야 한다.
아니면 ``initWith()``를 사용하여 ``release``의 설정을 복사해 와도 된다.
buildTypes {
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
}
}
테스트 코드를 작성하기 전에 build variant가 benchmark인지 확인하자.
벤치마크 코드 살펴보기
Android Studio 템플릿을 통해 ``benchmark`` 모듈을 만들었다면, ``ExampleStartupBenchmark``라는 이름의 샘플 테스트 코드가 있을 것이다.
@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.ku_stacks.ku_ring",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}
``benchmarkRule.measureRepeated()`` 함수의 매개변수를 하나씩 살펴보자.
- ``packageName``: 실행할 앱의 패키지 이름을 명시한다. Activity가 아닌 앱을 실행한다는 점에 주의.
- ``metrics``: 측정할 항목을 명시한다. ``StartupTimingMetric``, ``FrameTimingMetric``을 측정할 수 있으며, ``TraceScetionMetrics``와 ``PowerMetrics``는 현재 experimental 단계이다. 자세한 내용은 이 링크 참고.
- ``iteration``: 반복 측정할 횟수이다.
- ``startupMode``: 앱을 실행할 모드를 결정한다. ``COLD``, ``WARM`` 등의 모드를 지정할 수 있다.
- ``setupBlock``: 앱을 실행한 후 측정 준비 작업을 명시한다. 위 코드에서는 기본값인 ``{}``(empty lambda)를 사용하고 있다.
- ``measureBlock``: 성능을 측정할 행동을 명시한다. 마지막 매개변수이므로 trailing lambda로 작성할 수 있다.
벤치마크 코드 작성
이제 벤치마크 코드를 작성해 보자.
나는 온보딩 화면에 있는 ``FeatureTabItems`` composable의 스와이프 성능을 측정하고 싶다. 측정 방식은 실제로 스와이프를 해 보는 것. 그러려면 벤치마크에서 ``FeatureTab``을 찾을 수 있어야 한다.
``BySelector``를 사용하여 UI 객체를 찾을 수 있다. 나는 test tag를 resource id로 지정하고, resource id를 통해 ``FeatureTabItems``를 찾아 보겠다.
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun FeatureTabs() {
Column(
modifier = modifier
.background(KuringTheme.colors.background)
.fillMaxSize()
.semantics {
// test tag가 resource id로 인식되도록 설정
testTagsAsResourceId = true
},
) {
FeatureTabItems(
tabItems = tabItems,
pagerState = pagerState,
modifier = Modifier
.fillMaxWidth()
// test tag 설정
.testTag("feature_tab"),
)
}
}
UI에서 ``feature_tab``을 찾아보자. 온보딩 화면 전에 스플래시 화면이 먼저 보이므로, ``feature_tab``이 화면에 보일 때까지 기다린 후 찾아야 한다. 기다리는 시간은 20초로 설정했다.
@Test
fun swipeFeatureTabs() = benchmarkRule.measureRepeated(
packageName = "com.ku_stacks.ku_ring",
metrics = listOf(FrameTimingMetric()),
iterations = 10,
startupMode = StartupMode.WARM,
setupBlock = {
pressHome()
startActivityAndWait()
// 기다리기
device.wait(Until.hasObject(By.res("feature_tab")), 20_000)
},
) {
val featureTabs = device.findObject(By.res("feature_tab"))
// TODO: 스와이프하기
}
이제 ``FeatureTabItems``를 좌우로 스와이프하고, 프레임 성능을 측정해 보자.
val featureTabs = device.findObject(By.res("feature_tab"))
featureTabs.swipe(Direction.LEFT, 1.0f)
featureTabs.swipe(Direction.LEFT, 1.0f)
featureTabs.swipe(Direction.RIGHT, 1.0f)
featureTabs.swipe(Direction.RIGHT, 1.0f)
device.waitForIdle()
측정 결과
``frameDurationCpuMs``는 프레임 생성 시간을, ``frameOverrunMs``는 실제 생성 시간과 프레임 생성 제한시간의 차를 측정한다. 당연히 둘 다 작을수록 좋다.
그런데 측정 결과를 보면 둘 다 심각하다;; 프레임 생성 시간의 중위값(P50)은 8.5ms로 준수하지만, 하위 5% 프레임의 생성 시간(P95)는 무려 262.8ms다. 초당 4프레임도 못 그리는 수준.
측정 결과 하단의 ``Iteration`` 숫자를 클릭하면 profiler를 확인할 수 있다. 4번 iteration의 profiler를 한번 살펴보자.
딱 봐도 bitmap을 가져오는 부분이 문제임을 알 수 있다. 무려 4096×4096px짜리 이미지를 가져오고 있기 때문;;
상단의 빨간 네모 위에 마우스를 올리면 delay 정보를 자세히 볼 수 있다.
프레임 하나를 그리는 데 무려 307ms나 썼다. 초당 3프레임밖에 안 되는;;
해결?
이미지가 쓸데없이 큰 것이 원인으로 보인다. 원본 크기는 4096px*4096px인데, UI에서 보이는 크기는 300dp 정도이기 때문.
이 글과는 별개로 디자인팀에 성능 개선 방법을 문의했는데, 놀랍게도 이미지 크기를 줄여 보자는 의견을 주셨다. 디자이너의 감이란...
해결
의견에 따라 이미지의 가로/세로를 각각 1/4로 줄였다. 성능을 다시 측정해 보자.
매우 극적으로 개선되었다! 프레임의 95%가 6.8ms 안에 생성되었다. 프레임으로 환산하면 147FPS.
frameOverrunMS의 P90이 음수인데, 이는 프레임의 90%를 제한시간보다 빠르게 생성했음을 의미한다. frameOverrunMs가는 프레임 생성 제한시간과 실제로 생성하는 데 걸린 시간의 차이다. 음수 값은 프레임이 제한시간보다 빠르게 생성되었음을 의미한다.
Profiler를 확인해 보면, 전체 프레임 중 단 3개만 제한 시간을 초과했음을(jank) 알 수 있다. 실제 프로덕션에서 프레임 드랍이 느껴지지 않는 수준이므로, 이 정도면 충분하다.
결론
프레임 생성 시간 (하위 5%; fps 환산)
- 개선 전: 262.8ms (4fps)
- 개선 후: 6.8ms (147fps)
프레임 오버런 (하위 5%)
- 개선 전: 353.9ms
- 개선 후: 1.5ms
마치며
지금까지는 성능 문제를 해결할 때 감에 의존하는 경우가 많았다. 수치를 측정하지 않다 보니 나 스스로도 작업의 효용성을 느끼기 힘들었고, 남들에게 설명하기는 더더욱 어려웠다.
성능을 실제로 측정해본 건 이번이 처음인데, 어떤 일의 결과를 숫자로서 전달하는 경험을 할 수 있었다. 단순히 "빨라졌다"보단 이렇게 숫자 기반으로 의사결정하는 엔지니어가 되고 싶다.