ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Macrobenchmark를 이용한 성능 분석
    Android 2025. 9. 13. 16:03

    수치 기반으로 이야기하기

    개발을 업으로 삼으면서 개발 과정에서 생기는 프로젝트의 문제점을 파악하고 해결하는 것을 담당하고 있다.

    프로젝트 개발 과정에서 문제점을 인지하는 것에 그치는 것이 아니라 문제를 정확히 분석하는 것이 중요하다.

    내가 생각한 문제가 사실은 다른 원인으로 발생하는 것이라면 의미 없는 리소스에 시간을 버리는 것이다.

     

    인지 가능한 문제가 어떤 케이스, 빈도, 시간에 발생하는지 명확해야 올바른 해결책을 제시할 수 있다.

    이를 위해선 가능한 모든 문제를 수치 기반으로 이야기해야 한다. 해결 또한 마찬가지다.

    기존 대비 얼마 큼의 개선했는지를 숫자로 이야기할 수 있어야 한다.

     

    캐시 전략 도입, 화면 로딩 속도 개선을 진행하며 사용한 성능 분석 도구를 공유한다.

     

    성능 분석 도구, Macrobenchmark

     

    간단한 측정을 할 땐 코틀린에서 제공하는 measureTimeMillis를 사용할 수 있으나

    Cold Start 시점 측정 불가, 노이즈 간섭 등의 제약으로 제대로 된 분석 도구로써 신뢰하긴 어렵다.

     

    Composition 과정의 프레임 단위, API 호출 시 대기 시간, 앱 실행 시 로드 시간 등
    안드로이드 개발 과정에서 측정하고 분석할 수 있는 대상은 다양하다.

     

    안드로이드 생태계도 걸맞은 분석 도구를 제공한다.

    대표적으로 benchmark가 있으며 그중에서도 Macrobenchmark를 이용해 보았다.

     

    Macrobenchmark는 앱을 시작하거나 스크롤, 애니메이션 등의 복잡한 UI 조작 등을 측정하는 데 사용한다.
    지정한 횟수만큼 테스트를 진행하며 최소, 중간, 최대 시간을 측정할 수 있으며 테스트를 마치면 Profile을 제공한다.

    Macrobenchmark로 측정할 수 있는 항목은 다음과 같다.

     

    • StartupTimingMetric
      • 최초 프레임 구성 시간과 최종 렌더링 시간을 측정한다.
        • timeToInitialDisplayMs: UI 최초 프레임이 그려질 때까지의 시간
        • timeToFullDisplayMs: 모든 네트워크, 이미지 로드 등 비동기 작업까지 완료되어 화면이 완전히 보일 때까지의 시간
    • TraceSectionMetric
      • 특정 구간을 정의하여 측정한다.
        • SectionNameCount: 측정한 트레이스 구간의 측정 횟수
        • SectionNameSumMs: 구간 전체 실행에 소요된 시간
    • FrameTimingMetric
      • 앱 실행/애니메이션/리스트 스크롤/화면 전환 등의 UI 프레임 별 렌더링 성능을 측정한다.
        • frameDurationCpuMs : 각 프레임의 CPU에서 처리에 걸린 시간
        • frameDurationGpuMs : 각 프레임의 GPU에서 처리에 걸린 시간
        • jank: 60Hz 기준, 16.67ms이상 소요된 프레임 (끊김, 울렁거림)
        • frameOverrunMs : 프레임 지연 시간

     

    이 중에서 FrameTimingMetric는 아직 사용해보지 않아 추후 프레임 단위의 측정 시 다뤄볼 예정이다.

    @RunWith(AndroidJUnit4::class)
    class ExampleBenchmark {
        @get:Rule
        val benchmarkRule = MacrobenchmarkRule()
    
        @OptIn(ExperimentalMetricApi::class)
        @Test
        fun measureLoadHomeScreen() = benchmarkRule.measureRepeated(
            packageName = "com.pachuho.example",
            metrics = listOf(
                StartupTimingMetric(),
                TraceSectionMetric(R_SECTION_NAME_GET_HOME_WHOLE),
                TraceSectionMetric(R_SECTION_NAME_GET_HOMES_BY_USER),
                TraceSectionMetric(R_SECTION_NAME_GET_HOMES_DETAIL),
                TraceSectionMetric(R_SECTION_NAME_GET_WEATHER),
                TraceSectionMetric(R_SECTION_NAME_GET_WEATHER_DETAIL),
                TraceSectionMetric(R_SECTION_NAME_GET_SCENES),
                TraceSectionMetric(R_SECTION_NAME_GET_SCENE_ACTIONS),
                TraceSectionMetric(L_SECTION_NAME_GET_CURRENT_HOME_ID),
                TraceSectionMetric(L_SECTION_NAME_GET_HOMES),
            ),
            iterations = 5,
            startupMode = StartupMode.COLD,
            setupBlock = { }
        ) {
            startActivityAndWait()
        }
    }

     

    안드로이드 프레임워크에서 benchmark 모듈을 추가하면 MacrobenchmarkRule 기반의 반복 측정 함수가 구현되어 있다.

    구현되어 있는 함수에 측정 대상을 추가했다.

     

    StartupTimingMetric

    최초 UI 표시와 최종 화면 구성을 측정한다. 최종 화면 구성 시간을 측정하기 위해선 마지막으로 그려지는 컴포넌트에 ReportDrawn를 호출하거나 XML 기반이라면 Activity.reportFullyDrawn을 호출한다. 이를 통해 benchmark에게 모든 UI 컴포넌트가 그려졌는지 알려줄 수 있다.

     

    TraceSectionMetric

    사전에 정의한 측정 구간들을 측정한다. 예시에선 호출하는 API에 대해 측정 구간을  대상으로 한다. 동기 처리를 측정한다면 라이브러리 추가 없이 안드로이드 내장 라이브러리의 Trace 클래스를 이용할 수 있지만 androidx.tracing 라이브러리를 이용한다면 인라인 함수를 제공한다.

    public inline fun <T> trace(label: String, block: () -> T): T {
        androidx.tracing.Trace.beginSection(label)
        try {
            return block()
        } finally {
            androidx.tracing.Trace.endSection()
        }
    }

     

    측정의 시작과 종료는 쌍으로 이루어져야 하는데 조건문 등의 분기 처리 시 누락되는 경우가 발생할 수 있다. 그래서 try / finally 등을 이용하는 것을 권장한다. 이때 인라인 함수를 사용한다면 추가 구현 없이 간편하게 동기 처리를 측정할 수 있다. API 호출 소요 시간 같은 비동기 처리를 측정하기 위해선 traceAsync 함수를 이용한다.

     

    public suspend inline fun <T> traceAsync(
        methodName: String,
        cookie: Int,
        crossinline block: suspend () -> T
    ): T {
        androidx.tracing.Trace.beginAsyncSection(methodName, cookie)
        try {
            return block()
        } finally {
            androidx.tracing.Trace.endAsyncSection(methodName, cookie)
        }
    }

     

    trace와 다른 점은 쿠키 값이 추가되었다는 점이다. 쿠키는 동시에 발생하는 이벤트를 구별하기 위한 고유 식별자로 측정과정에서 발생하는 동시성을 보장하기 위한 일종의 태그 값이다. 이를 위해 측정 시 매번 쿠키 값을 다르게 처리해야 정확한 측정 결과를 도출할 수 있다.

     

    나는 비동기 처리 측정 시 앱 전역에서 간편하게 사용하기 위해 다음 함수를 생성했다.

    object TraceCookieManager {
        private val nextTraceCookie = AtomicInteger(0)
    
        fun getNextCookie(): Int = nextTraceCookie.getAndIncrement()
    }
    
    suspend inline fun <T> traceSuspending(sectionName: String, crossinline block: suspend () -> T): T {
        val cookie = TraceCookieManager.getNextCookie()
        return traceAsync(methodName = sectionName, cookie = cookie, block = block)
    }

     

    싱글톤 형태로 쿠키를 관리하되 쿠키는 AtomicInteger 기반으로 원자성을 보장하며 호출될 때마다 값을 증가시킨다.

     

    측정 결과

    진행 중이 프로젝트에 coldStart 기준 20회 반복테스트를 진행했다. 벤치마크의 경우 초기 도입에 비용이 들지만 장기적인 관점에서 원하는 시점에 손쉽게 데이터를 수집할 수 있다는 게 매력적이다.

     

     

    "*Count"는 측정 구간동안 측정 횟수를 의미하며 1에 가까울수록 결과를 신뢰할 수 있다.

    "*SumMs"는 측정 시간을 의미하며 반복 과정에서 발생한 최소 값, 중간 값, 최대 값을 제공한다. 시시각각 변화하는 단말 상태나 네트워크 상태에 따라 값에 편차가 있을 수 있다. 편차를 줄이기 위해선 반복 횟수를 늘려 모수를 늘리는 것이 중요하다.

     

     

    Traces의 숫자를 클릭하면 안드로이드 스튜디오에서 제공하는 Profile을 통해 측정 결과를 자세히 살펴볼 수 있다.

     

    다만 너무 작은 측정 단위의 경우 범위가 좁기 때문에 확인이 어렵다. Profile은 간편하지만 검색기능을 제공하지 않는다.

    이때 Perfetto 사용하면 trace를 더욱 자세하게 분석할 수 있다.

     

    benchmark/build/outputs/connected_android_test_additional_output/benchmark/connected/${PhoneName}

     

    벤치마킹로 생성된 trace 파일은 해당 경로에 생성된다. 파일을 찾아서 Perfetto에 업로드하고 "W", "A", "S", "D" 같은 기본 단축키를 사용하며 프레임 구간을 쉽게 확인할 수 있다.

     

     

    빨간 네모로 표시한 측정 구간처럼 매우 찰나의 순간도 검색하고 확인할 수 있으며 높은 시인성을 바탕으로 분석이 가능하다.

Designed by Tistory.