제품 뉴스

18% 더 빠른 컴파일, 0% 의 타협

8분 읽기

Android 런타임 (ART)팀은 컴파일된 코드나 최대 메모리 회귀를 타협하지 않고 컴파일 시간을 18% 단축했습니다. 이 개선사항은 메모리 사용량이나 컴파일된 코드의 품질을 희생하지 않고 컴파일 시간을 개선하기 위한 2025년 이니셔티브의 일환이었습니다.

컴파일 시간 속도 최적화는 ART에 매우 중요합니다. 예를 들어 적시 (JIT) 컴파일은 애플리케이션의 효율성과 전반적인 기기 성능에 직접적인 영향을 미칩니다. 컴파일 속도가 빠르면 최적화가 시작되기 전의 시간이 단축되어 더 원활하고 반응성이 뛰어난 사용자 환경을 제공합니다. 또한 JIT와 사전 (AOT) 모두 컴파일 시간 속도 개선은 컴파일 프로세스 중에 리소스 소비를 줄여 배터리 수명과 기기 온도에 도움이 됩니다. 특히 저가형 기기에서 그렇습니다.

이러한 컴파일 시간 속도 개선사항 중 일부는 2025년 6월 Android 출시에서 시작되었으며 나머지는 연말 Android 출시에서 제공될 예정입니다. 또한 버전 12 이상의 모든 Android 사용자는 메인라인 업데이트를 통해 이러한 개선사항을 받을 수 있습니다.

최적화 컴파일러 최적화

컴파일러 최적화는 항상 절충의 게임입니다. 속도를 무료로 얻을 수는 없습니다. 무언가를 포기해야 합니다. Google은 컴파일러를 더 빠르게 만들되 메모리 회귀를 도입하지 않고, 무엇보다도 생성하는 코드의 품질을 저하시키지 않는다는 매우 명확하고 어려운 목표를 세웠습니다. 컴파일러가 더 빠르지만 앱이 더 느리게 실행된다면 실패한 것입니다.

Google이 기꺼이 투자한 유일한 리소스는 이러한 엄격한 기준을 충족하는 영리한 솔루션을 깊이 파고들어 조사하고 찾는 데 필요한 자체 개발 시간이었습니다. 개선할 영역을 찾고 다양한 문제에 대한 올바른 솔루션을 찾는 방법을 자세히 살펴보겠습니다.

가치 있는 최적화 가능성 찾기

측정항목을 최적화하려면 먼저 측정할 수 있어야 합니다. 그렇지 않으면 개선했는지 여부를 확인할 수 없습니다. 다행히도 변경 전후에 측정에 사용하는 동일한 기기를 사용하고 기기의 온도 조절을 하지 않는 등 몇 가지 예방 조치를 취하는 한 컴파일 시간 속도는 상당히 일관됩니다. 또한 Google은 컴파일러 통계와 같은 결정론적 측정항목을 통해 내부에서 어떤 일이 일어나고 있는지 파악할 수 있습니다.

 

이러한 개선사항을 위해 희생한 리소스는 개발 시간이었으므로 최대한 빠르게 반복할 수 있기를 원했습니다. 즉, 솔루션을 프로토타입으로 만들기 위해 몇 가지 대표적인 앱 (퍼스트 파티 앱, 서드 파티 앱, Android 운영체제 자체의 조합)을 가져왔습니다. 나중에 광범위한 수동 및 자동 테스트를 통해 최종 구현이 가치가 있는지 확인했습니다.

 

엄선된 APK 세트를 사용하여 로컬에서 수동 컴파일을 트리거하고 컴파일 프로필을 가져오고 pprof를 사용하여 시간을 보내는 위치를 시각화합니다.

image.png

pprof의 프로필 Flame 그래프 예시

pprof 도구는 매우 강력하며 데이터를 슬라이스, 필터링, 정렬하여 예를 들어 어떤 컴파일러 단계 또는 메서드가 가장 많은 시간을 차지하는지 확인할 수 있습니다. pprof 자체에 관해서는 자세히 설명하지 않겠습니다. 막대가 클수록 컴파일에 더 많은 시간이 걸렸다는 것만 알아두세요.

이러한 뷰 중 하나는 가장 많은 시간을 차지하는 메서드를 확인할 수 있는 '하향식' 뷰입니다. 아래 이미지에서 컴파일 시간의 1% 이상을 차지하는 Kill이라는 메서드를 확인할 수 있습니다. 다른 상위 메서드 중 일부도 블로그 게시물의 뒷부분에서 설명합니다.

image.png

프로필의 하향식 뷰

최적화 컴파일러에는 전역 값 번호 매기기 (GVN)라는 단계가 있습니다. 전체적으로 어떤 작업을 하는지 걱정할 필요는 없지만 관련 부분은 필터에 따라 일부 노드를 삭제하는 `Kill` 이라는 메서드가 있다는 것을 아는 것입니다. 모든 노드를 반복하고 하나씩 확인해야 하므로 시간이 오래 걸립니다. 이 시점에서 활성 상태인 노드와 관계없이 검사가 거짓임을 미리 알고 있는 경우가 있다는 것을 확인했습니다. 이러한 경우 반복을 완전히 건너뛰어 1.023% 에서 ~0.3% 로 줄이고 GVN의 런타임을 ~15% 개선할 수 있습니다.

가치 있는 최적화 구현

시간이 소비되는 위치를 측정하고 감지하는 방법을 설명했지만 이는 시작에 불과합니다. 다음 단계는 컴파일에 소요되는 시간을 최적화하는 방법입니다.

일반적으로 위의 `Kill` 과 같은 경우 노드를 반복하는 방법을 살펴보고 예를 들어 작업을 병렬로 수행하거나 알고리즘 자체를 개선하여 더 빠르게 수행합니다. 실제로 처음에는 그렇게 시도했고 할 일을 찾을 수 없을 때만 '잠깐만...'이라는 순간이 있었고 솔루션은 (경우에 따라) 전혀 반복하지 않는 것이었습니다. 이러한 종류의 최적화를 수행할 때는 나무만 보고 숲을 보지 못하기 쉽습니다.

다른 경우에는 다음을 비롯한 몇 가지 다양한 기법을 사용했습니다.

  • 휴리스틱을 사용하여 최적화가 가치 있는 결과를 생성하지 못하므로 건너뛸 수 있는지 결정
  • 추가 데이터 구조를 사용하여 계산된 데이터 캐시
  • 속도 향상을 위해 현재 데이터 구조 변경
  • 경우에 따라 주기를 피하기 위해 결과를 지연 계산
  • 올바른 추상화 사용 - 불필요한 기능은 코드 속도를 저하시킬 수 있음
  • 많은 로드를 통해 자주 사용되는 포인터를 추적하지 않음

최적화를 추구할 가치가 있는지 어떻게 알 수 있나요?

그것이 멋진 부분입니다. 알 수 없습니다. 영역에서 많은 컴파일 시간이 소비되는 것을 감지하고 개선하기 위해 개발 시간을 투자한 후에도 솔루션을 찾을 수 없는 경우가 있습니다. 아무것도 할 일이 없거나 구현하는 데 너무 오래 걸리거나 다른 측정항목이 크게 회귀하거나 코드베이스 복잡성이 증가하는 등의 문제가 있을 수 있습니다. 이 블로그 게시물에서 볼 수 있는 모든 성공적인 최적화에는 결실을 맺지 못한 수많은 최적화가 있다는 것을 알아두세요.

비슷한 상황에 있다면 최대한 적은 작업으로 측정항목을 얼마나 개선할 수 있는지 추정해 보세요. 즉, 다음 순서대로 진행합니다.

  1. 이미 수집한 측정항목 또는 직감으로 추정
  2. 빠르고 대략적인 프로토타입으로 추정
  3. 솔루션 구현

솔루션의 단점을 추정하는 것을 잊지 마세요. 예를 들어 추가 데이터 구조에 의존하는 경우 얼마나 많은 메모리를 사용하시겠습니까?

더 자세히 알아보기

더 이상 지체하지 않고 구현한 변경사항을 살펴보겠습니다.

FindReferenceInfoOf라는 메서드를 최적화하기 위한 변경사항을 구현했습니다. 이 메서드는 벡터의 선형 검색을 수행하여 항목을 찾았습니다. FindReferenceInfoOf가 O(n) 대신 O(1)이 되도록 데이터 구조를 명령어 ID로 색인화하도록 업데이트했습니다. 또한 크기 조정을 방지하기 위해 벡터를 미리 할당했습니다. 벡터에 삽입한 항목 수를 계산하는 추가 필드를 추가해야 했으므로 메모리가 약간 증가했지만 최대 메모리가 증가하지 않았으므로 작은 희생이었습니다. 이렇게 하면 LoadStoreAnalysis 단계가 34~66% 빨라져 컴파일 시간이 ~0.5~1.8% 개선됩니다.

여러 곳에서 사용하는 HashSet의 맞춤 구현이 있습니다. 이 데이터 구조를 만드는 데 상당한 시간이 걸렸고 그 이유를 알아냈습니다. 몇 년 전에는 이 데이터 구조가 매우 큰 HashSets를 사용하는 몇 곳에서만 사용되었으며 이를 위해 최적화되도록 조정되었습니다. 그러나 요즘에는 항목이 몇 개 없고 수명이 짧은 반대 방향으로 사용되었습니다. 즉, 이 거대한 HashSet을 만드는 데 주기를 낭비했지만 삭제하기 전에 몇 개의 항목에만 사용했습니다. 이 변경사항으로 컴파일 시간이 ~1.3~2% 개선되었습니다. 추가 보너스로 이전만큼 큰 데이터 구조를 사용하지 않았으므로 메모리 사용량이 ~0.5~1% 감소했습니다.

람다에 참조로 데이터 구조를 전달하여 복사를 방지함으로써 컴파일 시간을 ~0.5~1% 개선했습니다. 이는 원래 검토에서 누락되어 몇 년 동안 코드베이스에 있었습니다. pprof에서 프로필을 살펴본 덕분에 이러한 메서드가 많은 데이터 구조를 만들고 삭제하는 것을 확인하고 조사하고 최적화할 수 있었습니다.

계산된 값을 캐시하여 컴파일된 출력을 쓰는 단계를 가속화했으며 이는 총 컴파일 시간 개선의 ~1.3~2.8% 에 해당합니다. 안타깝게도 추가 부기가 너무 많았고 자동 테스트에서 메모리 회귀를 알려주었습니다. 나중에 동일한 코드를 다시 살펴보고 메모리 회귀를 처리할 뿐만 아니라 컴파일 시간을 ~0.5~1.8% 더 개선하는 새 버전을 구현했습니다. 이 두 번째 변경사항에서는 두 데이터 구조 중 하나를 제거하기 위해 이 단계가 작동하는 방식을 리팩터링하고 재구상해야 했습니다.

최적화 컴파일러에는 성능을 개선하기 위해 함수 호출을 인라인하는 단계가 있습니다. 인라인할 메서드를 선택하기 위해 계산을 수행하기 전에 휴리스틱과 작업을 수행한 후 인라인을 완료하기 직전에 최종 검사를 모두 사용합니다. 인라인이 가치가 없다고 감지되면 (예: 너무 많은 새 명령어가 추가됨) 메서드 호출을 인라인하지 않습니다.

시간이 오래 걸리는 계산을 수행하기 전에 인라인이 성공할지 여부를 추정하기 위해 '최종 검사' 카테고리에서 '휴리스틱' 카테고리로 두 개의 검사를 이동했습니다. 이는 추정이므로 완벽하지는 않지만 새 휴리스틱이 성능에 영향을 미치지 않고 이전에 인라인된 항목의 99.9% 를 포함하는지 확인했습니다. 이러한 새 휴리스틱 중 하나는 필요한 DEX 레지스터 (~0.2~1.3% 개선)에 관한 것이고 다른 하나는 명령어 수 (~2% 개선)에 관한 것이었습니다.

여러 곳에서 사용하는 BitVector의 맞춤 구현이 있습니다. 크기 조정 가능한 BitVector 클래스를 특정 고정 크기 비트 벡터의 더 간단한 BitVectorView로 대체했습니다. 이렇게 하면 일부 간접 및 런타임 범위 검사가 제거되고 비트 벡터 객체 생성이 가속화됩니다.

또한 BitVectorView 클래스는 이전 BitVector처럼 항상 uint32_t를 사용하는 대신 기본 저장소 유형에서 템플릿화되었습니다. 이렇게 하면 일부 작업(예: Union())이 64비트 플랫폼에서 두 배 많은 비트를 함께 처리할 수 있습니다. Android OS를 컴파일할 때 영향을 받는 함수의 샘플이 총 1% 이상 감소했습니다. 이는 여러 변경사항[123456]에서 수행되었습니다.

모든 최적화에 관해 자세히 설명하면 하루 종일 여기 있을 것입니다. 다른 최적화에 관심이 있다면 구현한 다른 변경사항을 살펴보세요.

결론

ART의 컴파일 시간 속도를 개선하기 위한 Google의 노력은 상당한 개선을 가져왔으며, Android를 더 유연하고 효율적으로 만들면서 배터리 수명과 기기 온도에도 기여했습니다. 최적화를 부지런히 식별하고 구현함으로써 메모리 사용량이나 코드 품질을 타협하지 않고도 상당한 컴파일 시간 이득을 얻을 수 있음을 입증했습니다.

Google의 여정에는 pprof와 같은 도구를 사용한 프로파일링, 반복 의지, 때로는 덜 유익한 방법을 포기하는 것도 포함되었습니다. ART팀의 공동 노력은 컴파일 시간을 상당한 비율로 단축했을 뿐만 아니라 향후 발전을 위한 토대를 마련했습니다.

이러한 모든 개선사항은 2025년 연말 Android 업데이트와 Android 12 이상에서 메인라인 업데이트를 통해 제공됩니다. 최적화 프로세스에 대한 심층 분석을 통해 컴파일러 엔지니어링의 복잡성과 보상에 관한 유용한 정보를 얻으시기를 바랍니다.

작성자:

계속 읽기