사례 연구

R8로 Android에서 Kotlin 코루틴을 2배 더 빠르게 만드는 방법

7분 읽기

AGP 9.2.0부터 R8은 대부분의 Atomic*FieldUpdater 호출을 일반 작업에서 2~4배 더 나은 성능을 발휘하는 Unsafe 변형으로 최적화합니다. 이는 kotlinx.coroutines의 원자를 구현하는 kotlinx.atomicfu 라이브러리에 특히 큰 영향을 미쳐 코루틴을 최대 2배 더 빠르게 실행하고 취소할 수 있습니다. 이점을 얻으려면 AGP를 9.2.0 이상으로 업데이트하세요.

대부분의 Android 앱이 Kotlin을 기본 언어로 채택함에 따라 kotlinx.coroutines가 비동기 프로그래밍의 사실상 표준이 되었습니다. 이 라이브러리는 Kotlin에 기본적으로 제공되는 동시 흐름을 관리하는 잘 설계되고 구조화된 방법을 제공합니다. Jetpack Compose도 예외는 아니었으며 포인터 이벤트, 애니메이션, 기타 상호작용을 관리하기 위해 코루틴을 채택했습니다. 작성 당시 Compose의 대부분의 동시 API는 내부적으로 suspend 함수를 호출하고 업데이트를 처리하기 위해 코루틴을 실행하거나 취소합니다.

Compose팀이 성능 조사를 시작하면서 코루틴이 구성 외부에서 발생하는 많은 작업의 병목 현상인 것으로 밝혀졌습니다. 예를 들어 Modifier.clickable을 만들고 업데이트하는 데 걸리는 시간의 80% 는 InteractionSource 업데이트를 처리하는 내부 코루틴을 실행하고 취소하는 데 사용되었습니다. 이러한 관찰을 바탕으로 초기 성능 작업의 대부분은 기본 경로에서 코루틴을 삭제하고 필요할 때까지 초기화를 지연하는 데 중점을 두었습니다. 

코루틴 비용

Android에서 함수의 내부 동작을 분석하는 가장 쉬운 방법은 Android 런타임 (ART) 메서드 트레이스를 캡처하는 것입니다. ART 메서드 트레이스는 앱의 실행 흐름을 기록하는 도구로, 호출되는 메서드, 순서, 각 메서드에 소요되는 시간을 정확하게 보여주므로 개발자가 성능 병목 현상을 식별할 수 있습니다. 빈 LaunchedEffect { } 호출의 경우 다음과 같이 표시됩니다.

pic01_enhanced.png
Perfetto UI에서 시각화된 LaunchedEffect 메서드 트레이스

위의 메서드 트레이스는 세 부분으로 나눌 수 있습니다.

  • 새 코루틴 초기화
  • 코루틴 시작
  • 코루틴 완료 (즉시 종료되므로)

LaunchedEffect 취소는 CancellationException을 생성한다는 점을 제외하고 일반 완료와 비슷합니다.

위의 프로필에서 즉시 의심스러운 점은 java.util.concurrent.AtomicReferenceFieldUpdater에 대한 빈번한 호출 (j… 라벨이 있는 보라색 또는 녹색 상자)입니다. 각 호출은 비교적 빠르지만 빈도가 우려됩니다. 여러 호출에 분산된 무시할 수 없는 오버헤드는 눈에 띄는 회귀로 이어질 수 있습니다. 호출을 확대하면 대부분의 시간이 리플렉션 검사에 소요되는 것으로 나타납니다.

pic02-enhanced.png
LaunchedEffect 초기화 중에 AtomicReferenceFieldUpdater.get의 메서드 트레이스를 자세히 살펴봅니다.

코루틴은 구조화된 동시 실행을 가능하게 하는 상위-하위 요소 관계를 위한 잠금 없는 트리 구조를 구현합니다. 알고 보니 kotlinx.atomicfu 라이브러리는 잘 알려진 JVM 기본 요소인 AtomicReferenceFieldUpdater를 사용하여 잠금 없는 원자 작업을 구현합니다. 업데이터는 클래스 참조와 필드 이름을 사용하여 런타임에 원자 작업을 실행하며 필드가 존재하고 액세스할 수 있는지 확인하기 위해 여러 리플렉션 안전 검사를 실행해야 합니다. 코루틴의 각 작업 (시작, 일시중지, 취소, 완료)은 하나 이상의 원자 작업을 호출하므로 느리면 코루틴이 제대로 실행되지 않습니다.

AtomicReferenceFieldUpdater 조사

하지만 너무 앞서가지 마세요.AtomicReferenceFieldUpdater는 실제로 JVM에서 10년 넘게 잘 최적화되어 있으며 메서드 트레이스는 VM 수준 최적화(JIT(just-in-time) 또는 AOT(ahead-of-time) 컴파일)로 완전히 삭제되는 오버헤드를 캡처할 수 있습니다. 성능을 확인하려면 kotlinx.atomicfujava.util.concurrent.atomic의 원자 참조 간의 차이를 측정하는 몇 가지 벤치마크를 작성해 보겠습니다.

@RunWith(AndroidJUnit4::class)
class AtomicReferenceBenchmark {
    @get:Rule
    val benchmarkRule = BenchmarkRule()
    
    private val atomicReference = java.util.concurrent.atomic.AtomicReference(false)
    private val atomicRef = kotlinx.atomicfu.atomic<Boolean>(false)
    
    @Test
    fun atomicReference_compareAndSet() {
        benchmarkRule.measureRepeated { 
            atomicReference.compareAndSet(true, false)
            atomicReference.compareAndSet(false, true)
        }
    }

     @Test
    fun atomicRef_compareAndSet() {
        benchmarkRule.measureRepeated {
            atomicRef.compareAndSet(true, false)
            atomicRef.compareAndSet(false, true)
        }
    }

    /* measuring other methods from the method traces above */
}

Pixel 5에서 이 벤치마크를 실행하면 (워밍업 중에 AtomicReferenceFieldUpdater#compareAndSet이 JIT 컴파일되도록 함) Pixel 5 (API 33)에서 다음과 같은 결과가 생성됩니다.

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

측정 결과는 격차를 확인하며 kotlinx.atomicfu 버전이 약 2.7배 더 느린 것으로 나타났습니다. 이는 ART가 숨겨진 최적화를 실행하지 않으며 리플렉션 액세스 검사가 런타임 중에 실제 오버헤드를 추가한다는 것을 확인합니다.

원래 메서드 트레이스를 다시 살펴보면 AtomicReferenceFieldUpdater에서 실행하는 유일한 의미 있는 작업은 기본 원자 작업을 실제로 실행하는 Unsafe.getObjectVolatile에 대한 내부 호출입니다. 대부분의 경우 업데이터 이니셜라이저는 정적이며 주변 클래스의 구조를 기반으로 항상 올바른 것으로 증명할 수 있습니다. 따라서 대부분의 AtomicReferenceFieldUpdater 사용을 정적으로 분석하고 컴파일 중에 내부 Unsafe 변형으로 대체할 수 있습니다. 또한 Android 빌드 도구 모음에는 정확히 그 작업을 실행할 수 있는 자체 최적화 컴파일러가 있습니다.

R8을 사용한 최적화

Atomic*FieldUpdater 클래스는 미묘하고 동적이며 리플렉션 기반 사용을 지원하지만 정적으로 명확한 패턴으로 자주 사용됩니다. 이는 느린 기준 성능과 최적화 요구사항을 모두 설명합니다. R8은 전체 프로그램 최적화 컴파일러이며 더 간단한 패턴을 통해 리플렉션 안전 검사의 오버헤드를 파악하는 데 적합합니다. R8은 Java 또는 Kotlin 컴파일러 후에 JVM 바이트코드를 수신하지만 가독성을 높이기 위해 이러한 예는 Java 문법으로 표시됩니다. 이것이 AtomicReferenceFieldUpdater에 유형 인수가 없는 이유입니다.

class Example {
    volatile String data = "";
    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");

    void example() {
        // ...
        updater.compareAndSet(this, "", "new");
        // ...
    }
}

기본 예에서는 홀더, 유형, 필드 이름에 대한 간단한 상수 인수로 휘발성 필드에 액세스하는 정적 최종 업데이터를 만듭니다. 사용된 리플렉션은 완전히 투명합니다. 이 업데이터가 유효한 필드를 참조하고 업데이터 생성 사이트가 필드에 유효하게 액세스할 수 있음을 알 수 있습니다.

본질적으로 Atomic*FieldUpdater는 필드 오프셋과 Unsafe 호출을 래핑합니다. 최적화의 최상의 시나리오는 업데이터 필드를 오프셋 필드로 바꾸고 업데이터 호출을 Unsafe 호출로 바꾸는 것입니다.

Atomic*FieldUpdater 최적화

최적화는 계측, 대체, 삭제의 세 부분으로 구현됩니다. 

계측

첫 번째 단계는 Unsafe 호출을 통한 직접 액세스를 용이하게 하기 위해 업데이터 필드와 함께 오프셋 필드를 도입하는 것입니다.

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))

필드는 리플렉션을 통해 액세스되며 Unsafe는 클래스에서 필드 오프셋을 추출하는 데 사용됩니다. 이 코드는 리플렉션 유효성 검사를 무시하는 경우 Atomic*FieldUpdater 의 내부를 나타냅니다. 대신 업데이터의 홀더 유형과 휘발성 필드의 필드 유형이 컴파일러에서 정적으로 추적됩니다.

 

원래 필드와 초기화는 그대로 유지됩니다. 최적화 프로세스는 낙관적으로 사용을 용이하게 하고 최적화한 후 나중에 정리합니다. 이는 구현에 대한 간단한 접근 방식이지만 일부 사용은 그대로 유지되고 다른 사용은 최적화되는 업데이터 필드의 부분 최적화도 허용합니다.

대체

컴파일러의 이 시점에서 적절한 동시 실행 조인 포인트 후에 계측된 업데이터 필드 목록이 있습니다. 즉, 몇 가지 조건을 기반으로 각 호출 사이트를 개별적으로 최적화할 수 있습니다. 호출 예를 살펴보세요.

updater.compareAndSet(holder, expectedValue, newValue);

Atomic*FieldUpdater에 필요한 조건은 다음과 같습니다.

  • updater가 계측된 필드에서 가져온 것인가요? 즉, 정적 분석이 객체의 값을 계측된 업데이터의 필드 읽기로 다시 추적할 수 있나요?
  • holder가 원래 정의된 홀더 유형과 동일한 클래스 또는 하위 클래스인가요?
  • newValue가 원래 정의된 필드 유형과 동일한 클래스 또는 하위 클래스인가요?

모든 조건이 충족되면 호출은 리플렉션 검사 없이 Unsafe 호출로 대체됩니다.

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)

이 새 호출은 더 빠르고 간단하지만 updaterholder의 null 값 처리와 관련하여 원래 호출과 다릅니다. 정적으로 제외되지 않는 한 null 검사가 둘 다 삽입됩니다. 

삭제

이 시점에서 보유 클래스에는 원래 업데이터 필드와 새 오프셋 필드가 있으며 둘 중 하나를 사용할 수 있는 호출 사이트가 있습니다. 호출 사이트가 최적화되지 않은 경우 오프셋 필드를 삭제해야 하고 모든 호출 사이트가 최적화된 경우 업데이터 필드를 삭제해야 합니다. 두 경우 모두 초기화 호출도 삭제해야 합니다. 사용되지 않는 필드 삭제 및 불량 코드 삭제는 이미 컴파일러에서 실행되지만 여기서 초기화 코드를 삭제하려면 몇 가지 추가 트릭이 필요합니다.

newUpdatergetDeclaredField 호출은 예외를 발생시킬 수 있으므로 부작용이 있을 수 있으며 구현은 API 버전에 따라 다르므로 알 수 없습니다. 즉, 일반 최적화로는 안전하게 삭제할 수 없습니다. 따라서 이러한 삭제에는 계측된 필드를 명시적으로 고려해야 했습니다. 이러한 필드는 예외가 없는 것으로 정적으로 알려져 있기 때문입니다.

결과적으로 위에 표시된 간단한 업데이터 예는 최적화 후 다음과 같이 표시됩니다.

결과

이러한 최적화 후 kotlinx.atomicfuAtomicInt/Long/ReferenceFieldUpdater의 대부분의 명시적 사용은 이제 R8이 적용된 AtomicReference 성능과 일치합니다. 실제로 일부 벤치마크에서는 더 빠릅니다. kotlinx.atomicfu에는 컴파일러 플러그인이 있어 atomic 인스턴스를 필드로 인라인하여 원자적으로 업데이트된 필드를 만드는 데 필요한 할당을 줄일 수 있습니다.

Jetpack Compose는 이 작업의 주요 수혜자였습니다. Compose 런타임에는 코루틴 성능을 매우 면밀히 추적하여 성능 회귀를 조기에 포착하는 여러 마이크로벤치마크가 있습니다. 벤치마크가 새 버전의 R8로 업데이트되었을 때 2배 향상된 것을 LaunchedEffect에서 코루틴을 실행하고 취소할 때 확인했습니다.

pic03_enhanced.png
LaunchedEffect에서 코루틴을 시작하고 취소하는 데 걸리는 시간을 보여주는 벤치마크 그래프 (낮을수록 좋음) 그래프의 변경사항은 R8 업데이트에 해당하며 2배 향상을 보여줍니다.

그 외에도 ART팀은 VM 수준에서 이러한 최적화를 기본적으로 구현하고 있습니다. 앱이 API 36을 타겟팅하고 최신 버전의 Android에서 실행되는 경우 기기가 이미 비슷한 방식으로 코루틴을 최적화하고 있을 수 있습니다. 위의 코루틴 벤치마크는 최신 버전의 ART에서 JIT 업데이트 후 성능이 약 15% 향상된 것으로 관찰되었습니다.

AGP 9.2.0으로 업그레이드하거나 R8 9.2.0을 직접 사용하면 앱에서 이 최적화를 기본적으로 받게 됩니다. 자세한 내용은 D8 dexer 및 R8 축소기를 참고하세요.

작성자:
계속 읽기