이동식 저장소

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

Primary/Android

Macrobenchmark로 프레임 성능 측정하기 - 심화

해스끼 2024. 10. 20. 22:06

이전 글에서 Android Macrobenchmark를 활용하여 프레임 성능을 개선하는 과정을 설명한 적이 있다.

 

Macrobenchmark로 프레임 성능 측정하기

문제 상황온보딩 화면에서 기능 소개 탭을 스크롤할 때 프레임 드랍이 발생하는 문제가 있다.이 글에서는 Macrobenchmark를 활용하여 프레임 성능을 측정하고, 성능 문제를 해결하는 연습을 해 보겠

thinking-face.tistory.com

회사 세미나에서 이 경험을 공유했는데, 팀 선배 분들께서 공부 의욕이 뿜뿜하는 질문을 주셨다. 한번 공부해 보자.

frameOverrunMs와 frameDurationCpuMs의 차이가 일정하지 않은 이유?

개선하기 전의 성능 측정 결과를 보자.

 

두 수치의 P50은 13.4ms 정도 차이나지만, P99의 차이는 무려 1,547.2ms이다. 원 글에서는 ``frameOverrunMs``가 actual duration - expected duration이라고 설명했는데, 위 결과대로라면 expected duration이 음수이며, 심지어 점점 작아진다는(?!) 말도 안 되는 결론이 나온다.

 

대체 무슨 일이 일어난 걸까?


먼저 ``frameDurationCpuMs``의 의미를 정확히 이해할 필요가 있다. 공식 문서에서는 즉 CPU가 프레임을 만드는 시간이라고 설명하고 있다.

How much time the frame took to be produced on the CPU - on both the UI Thread, and RenderThread. Note that this doesn't account for time before the frame started (before Choreographer#doFrame), as that data isn't available in traces prior to API 31.

 

그런데 프레임을 그리기 위해서는 CPU 외에도 GPU, 디스플레이 하드웨어가 작업을 수행해야 한다. 즉 CPU 시간은 프레임을 그리는 시간의 일부라는 것.

 

타임라인을 자세히 그려보면 다음과 같다.

 

  1. 새 프레임 요청: ``Choreographer#doFrame`` 또는 ``AChoreographer_vsyncCallback``이 호출되는 시간이다. 참고로 vsync는 수직 동기화를 의미한다.
  2. CPU 작업: View 또는 Compose UI element를 composition, layout, measure한다. UI 구성, 배치 등을 결정한다고 생각하면 된다.
  3. GPU 렌더링 시작: CPU에서 계산한 작업을 바탕으로 GPU에서 픽셀을 계산한다.
  4. GPU 렌더링 끝: 픽셀 계산이 종료된다.
  5. 픽셀 전송 완료: 계산한 픽셀을 ``SurfaceFlinger``에 전송한다. ``SurfaceFlinger``는 여러 앱에서 보낸 픽셀을 합쳐 프레임을 완성하고, 디스플레이 하드웨어에 프레임을 전송한다.

뭔가 모르는 말을 잔뜩 써놨는데, 자세한 내용은 아래 접은글에 서술하였다.

 

더보기

# Choreographer (열기)

 

프레임을 그리는 타이밍을 동기화한다.

 

GPU가 프레임을 그리는 빈도(fps)와 디스플레이가 업데이트될 수 있는 주기(주사율; hz)는 다를 수 있다. 예를 들어 GPU의 프레임 생성 속도가 느리다면, 디스플레이의 일부만 새 프레임으로 갱신되는 tearing(찢어짐) 현상이 발생할 수 있다.

 

이런 문제를 해결하려면 GPU와 디스플레이의 업데이트 빈도를 동일하게 맞춰야 한다. 프레임이 완전히 생성될 때까지 기다리는 수직 동기화 기술을 적용하면 된다.

 

수직 동기화 신호가 주어지면 ``Choreographer#doFrame()`` 또는 ``AChoreographer_vsyncCallback`` 함수 안에서 UI 콜백이나 애니메이션 작업이 수행된다. 그 후 render 스레드에서 layout measurement/placement 작업을 수행한다. 

 

GPU는 렌더 스레드의 결과를 바탕으로 실제 픽셀 데이터를 계산한다.

 

 

일반적으로 ``Choreographer#doFrame()``이 종료된 후 즉시 프레임 렌더링이 시작되지만, 프레임 드랍이 발생하는 경우에는 렌더링이 즉시 시작되지 않을 수 있다.

정상: 왼쪽 위의 Choreographer 함수가 끝난 직후 DrawFrame 작업이 실행되는 모습

 

비정상: 가운데 작은 부분에서 doFrame()이 실행되고, 약 60ms 후에 DrawFrame 작업이 실행되는 모습

 

더보기

# SurfaceFlinger (열기)

 

여러 개의 surface를 하나의 프레임으로 합성하여, 디스플레이 하드웨어에 보내는 역할을 한다.

 

프레임은 여러 개의 surface로 구성될 수 있다. Surface는 픽셀 데이터를 의미한다. 예를 들어 상단 바와 하단 내비게이션 바는 독립된 surface를 가지며, 화면에 보이는 포그라운드 앱은 하나 이상의 surface를 가질 수 있다. 

 

각 surface는 buffer queue를 통해 SurfaceFlinger에 전달된다. SurfaceFlinger는 각 buffer queue의 최신 surface만을 사용하여 프레임을 만든다.

코드 보기: services/surfaceflinger/SurfaceFlinger.cpp - platform/frameworks/native - Git at Google

 

더보기

# UI 스레드와 Render 스레드의 차이 (열기)

 

UI 스레드는 UI에 접근할 수 있는 유일한 스레드를 말한다. 뷰의 생명주기에 따라 ``onMeasure()``, ``onLayout()`` 등의 콜백을 실행하고, 렌더링에 필요한 정보를 수집하여 render 스레드에 전달한다. 렌더링 작업의 dispatcher 역할을 한다.

 

Render 스레드는 UI 스레드의 정보를 처리하여 UI를 구성하는 역할을 한다. UI 요소를 measure 및 layout하는 등의 작업이 수행된다. Compose로 따지면 composition과 layout(measure, placement) 단계이 수행된다고 보면 된다.

 

일반적으로 UI 스레드와 렌더 스레드는 synchronous하게 작동하지만, 애니메이션 등의 일부 작업을 수행할 때에는 비동기적으로 동작한다. 그래서 UI 스레드가 무거운 로직을 수행하고 있을 때에도 애니메이션을 부드럽게 재생할 수 있는 것.

 

코드 보기: libs/hwui/renderthread/RenderThread.cpp - platform/frameworks/base - Git at Google

 

프레임 생성 시간은 CPU 시간 외에도 많은 시간을 포함한다. 따라서 ``frameDurationCpuMs``만으로는 프레임 생성 시간을 정확히 알 수 없다.

 

프레임 성능을 정확히 파악하려면, 공식 문서에서 말하는 대로 ``frameOverrunMs``를 참고하는 것이 좋다.

Generally, prefer tracking and detecting regressions with frameOverrunMs when it is available, as it is the more complete data, and accounts for modern devices (including higher, variable framerate rendering) more naturally.

 


그렇다면 ``frameOverrunMs``는 왜 이렇게 커진 걸까? 간단하게 요약하면 다음과 같다.

  1. CPU 작업은 크게 ``Choreographer#doFrame()``과 렌더 스레드 작업으로 나눌 수 있다. 
  2. Profiler를 자세히 보니, ``Choreographer#doFrame()``은 비교적 빨리 끝난다. (약 2ms 정도)
  3. 그런데 ``Chronographer#doFrame()``이 종료된 후 렌더 스레드 작업이 즉시 실행되지 않는다. 렌더 스레드가 다른 작업으로 포화되었기 때문이다.
  4. 그 이유는 앞서 생성된 프레임의 렌더 스레드 작업이 지연되었기 때문. 위 사진만 봐도 빽빽하게 차 있다...
  5. 즉 이전 프레임에서 지연된 시간이 ``frameOverrunMs``에 점점 누적되는 구조이다. 

 

이 문제를 해결하려면 프레임을 아예 안 만드는 수밖에 없고, 그래서 프레임 드랍이 발생하는 것이다.

 

참고로 GPU 렌더링 시간은 거의 ㎲ 단위이기 때문에, 게임 같은 특수한 경우가 아니라면 GPU 문제일 가능성은 매우 낮다.

 

 

리소스 문제였다니 좀 허무하다. 안드로이드 기능을 사용해서 해결할 수는 없을까?

우선 dpi마다 다른 이미지 파일을 사용하는 방법이 있다. Dpi가 낮은 기기에서는 1024×1024보다 작은 파일을 사용하는 것. Dpi가 낮으면 기기 성능도 낮을 가능성이 높으므로 유효한 방법이다.

 

또는 이미지를 미리 로드해 두는 방법도 좋다. ``HorizontalPager``의 ``beyondViewportPageCount`` 매개변수를 활용하면 현재 보이는 페이지 전후로 로드해 둘 페이지 수를 정할 수 있다. 현재 탭이 3개이므로, ``beyondViewportPageCount``에 2를 주면 된다.

@Composable
private fun FeatureTabItems(
    tabItems: Array<FeatureTabItem>,
    pagerState: PagerState,
    modifier: Modifier = Modifier,
) {
    HorizontalPager(
        state = pagerState,
        modifier = modifier,
        beyondViewportPageCount = tabItems.size - 1,
    ) {
        FeatureTab(
            item = tabItems[it],
            modifier = Modifier.fillMaxWidth(),
        )
    }
}

 

beyondViewportPageCount 적용 전

 

beyondViewportPageCount 적용 후. Overrun P95 0.0ms!

저사양 기기에서도 유효한 해결책인지?

20만원짜리 태블릿에서도 테스트를 돌려 봤다. 무려 갤럭시 A14급의 성능을 자랑하는 태블릿이다.

 

참고로 A14는 작년 출시된 갤럭시 보급형 기기 중 최하위급 모델이다. A04가 있긴 한데 이건 거의 장난감 수준이라.

Before
After

솔루션 적용 후 overrun P95 2.2ms 수준으로, 충분히 부드러운 화면을 보여줬다. 따라서 웬만한 저사양 기기에서도 유효한 해결책이라고 생각.

References

 

FrameTimingMetric  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

 

 

UI jank detection  |  Android Studio  |  Android Developers

Learn about jank detection in Android Studio.

developer.android.com

 

Android Jank detection with FrameTimeline - Perfetto Tracing Docs

Actual Timeline These slices represent the actual time an app took to complete the frame (including GPU work) and send it to SurfaceFlinger for composition. The start time is the time that Choreographer#doFrame or AChoreographer_vsyncCallback started to ru

perfetto.dev

 

렌더링 되는 View의 내부를 살펴보자 | 찰스의 안드로이드

렌더링 되는 View의 내부를 살펴보자 더 나은 이해를 위해 이전 포스팅인 안드로이드 View가 렌더링 되는 과정을 먼저 참조할 수 있다. 렌더링하는 동안 사용되는 컴포넌트, 디스플레이 파이프 라

charlezz.com

 

안드로이드 프레임워크 프로그래밍(22) [SurfaceFlinger(시스템 서비스) 등록 및 초기화 과정]

※ 본 포스팅은 Android KitKat 4.4.4 를 기준으로 작성되었습니다. Kitkat 이전의 버전에서는 소스코드의 구조가 다름을 알립니다. 안드로이드 기반 디바이스에 있어 화면을 출력하는 데 가장 중요한

elecs.tistory.com

 

 

안드로이드 UI 안무가, Choreographer

CPU 프로파일링을 하다보면 Application Thread 에서 Choreographer#doFrame 함수가 호출되는 것을 볼 수 있다. 구글 문서상에는 아래와 같이 써져있었지만 이해하기 쉽지 않았다.

medium.com

 

 

Comments