이동식 저장소

Macrobenchmark로 프레임 성능 측정하기 본문

Primary/Android

Macrobenchmark로 프레임 성능 측정하기

해스끼 2024. 7. 21. 13:17

문제 상황

온보딩 화면에서 기능 소개 탭을 스크롤할 때 프레임 드랍이 발생하는 문제가 있다.

이 글에서는 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

마치며

지금까지는 성능 문제를 해결할 때 감에 의존하는 경우가 많았다. 수치를 측정하지 않다 보니 나 스스로도 작업의 효용성을 느끼기 힘들었고, 남들에게 설명하기는 더더욱 어려웠다.

 

성능을 실제로 측정해본 건 이번이 처음인데, 어떤 일의 결과를 숫자로서 전달하는 경험을 할 수 있었다. 단순히 "빨라졌다"보단 이렇게 숫자 기반으로 의사결정하는 엔지니어가 되고 싶다.

참고자료

 

Macrobenchmark 작성  |  App quality  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Macrobenchmark 작성 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 앱 시작과 복잡한 UI 조작(RecyclerView 스

developer.android.com

Comments