Dart와 sound 타입의 성능 이점

Dart와 sound 타입의 성능 이점

soundness와 null safety를 활용해 더 빠르고 더 작은 코드를 만들어내는 방법을 소개할게요. 같은 Dart 메서드를 Dart 1.24, 2.0, 2.12에서 각각 컴파일한 코드는 점점 작아졌어요. 그 이유와 실제 생성된 코드가 궁금하다면 계속 읽어보세요.

출처: Dart and the performance benefits of sound types

본문

지난 몇 년 동안 Dart는 타입 시스템을 계속 강화해 왔어요. 원래의 Dart 언어(Dart 1)는 sound하지 않고 선택적인(optional) 타입 시스템을 갖고 있었는데, 이는 Microsoft의 TypeScript나 Facebook의 Flow 같은 타입이 붙은 JavaScript 방언과 비슷한 수준이었죠. Dart 2에서는 더 엄격한 sound 타입 시스템을 도입했고, 지난 2년 동안은 sound null safety를 통해 타입 시스템을 더 확장해 왔어요.

sound 타입 시스템은 개발자에게 더 큰 확신을 주기도 하지만, 동시에 컴파일러가 타입 정보를 안전하게 활용해 생성된 코드를 최적화할 수 있게 해줘요. soundness가 있으면 도구는 정적 검사와 (필요할 때는) 런타임 검사를 함께 사용해 타입이 올바르다는 것을 보장해요. 반대로 soundness가 없으면 타입 검사가 할 수 있는 일이 제한적이고, 정적 타입이 런타임에 틀어질 수도 있어요.

실제로 soundness 덕분에 컴파일러는 더 작고 빠른 코드를 생성할 수 있어요. 특히 클라이언트에 미리 컴파일된 네이티브 코드를 배포하는 AOT(ahead-of-time) 환경에서 그 효과가 두드러져요.

예시

다음 예시 메서드는 sound 타입이 비교적 단순한 코드에도 얼마나 극적인 영향을 미치는지 보여줘요.

int getAge(Animal a) {
  return a.age;
}

마지막 안정 버전의 Dart 1(1.24.3)에서는 이 메서드가 26개의 네이티브 x64 명령어로 컴파일됐어요. 그것도 계측(instrumentation)과 프로파일 기반 최적화(profile-guided optimization)를 거친 뒤의 결과라서, 초기 런타임 시작이 느려졌죠. 그런데 Dart 2.12의 sound null safety에서는 프로파일 기반 최적화 없이 이 코드가 단 3개의 명령어로 컴파일돼요.

Dart는 ARM32/64와 x86/x64 아키텍처로 모두 컴파일돼요. 아래 예시에서는 x64를 사용하지만, 다른 대상에서도 결과는 비슷해요.

예시 메서드의 전체 Dart 코드와 맥락은 이 글의 끝부분에 나오지만, 핵심 포인트는 이렇습니다.

  • Animal 클래스에는 int 타입의 필드 age가 있어요.
  • Animal에는 여러 서브클래스가 있어요(Cat, Dog, Snake, Hamster).
  • 위 메서드는 런타임에 이 여러 타입에 대해 호출돼요.

Dart 객체 레이아웃

Animal 클래스를 네이티브(x64) 코드로 컴파일하면 단순한 레이아웃을 가져요. 첫 8바이트는 reified 타입 정보(즉 객체의 런타임 타입)를 제공하는 헤더이고, 두 번째 8바이트에 age 필드가 들어 있어요. 모든 서브클래스는 이 구조를 유지하면서(필요하면) 필드를 추가해요. 추가 필드는 기본 타입의 구조를 보존한 채 뒤에 배치되죠. getAge 메서드는 Animal(또는 어떤 서브클래스) 인스턴스가 주어지면 8바이트 오프셋에서 필드를 읽어 반환하면 됩니다.

Dart 1: Unsound 타입

그런데 Dart 1에서는 정적 타입이 sound하지 않아서 컴파일 중에 사실상 무시됐어요. 런타임에는 정적 타입이 올바르다고(따라서 레이아웃이 예상과 같다고) 가정할 수 없었죠. age에 대한 접근이 다른 오프셋의 필드일 수도, 추가 실행 코드를 유발하는 getter일 수도, 존재하지 않는 필드일 수도 있어서(잡을 수 있는 런타임 오류가 발생) 실제로는 그랬어요.

Dart 1은 클라이언트 기기의 JIT 컴파일러와 가상 머신에 의존하도록 설계됐어요. 이 방식은 런타임 타입 정보로 코드를 최적화했죠. 실제로 각 메서드를 두 번 컴파일했어요. 먼저 정보를 수집하고, 그다음(핫 메서드의 경우) 관찰된 런타임 동작을 바탕으로 더 최적화된 코드를 생성했어요.

Dart 1: 첫 번째 컴파일

getAge의 첫 번째 컴파일은 x64에서 47개의 명령어를 생성했어요. 이 코드는 런타임에 무슨 일이 일어나는지 알아내기 위해 계측되어 있어요. 전달된 객체에 대해 아무것도 가정하지 않고, 사실상 해시 테이블 조회와 같은 동작으로 필드를 올바르게 찾고, getter를 실행하거나 오류를 던지죠.

Dart 1: 두 번째 컴파일

이 경우 코드가 반복적으로 호출되면서 두 번째 최적화 컴파일이 발생했고, 26개의 명령어가 생성됐어요. 이 최적화된 코드도 여전히 꽤 큽니다. 프로파일 정보에서 메서드가 Cat, Hamster, Dog 인스턴스에서만 호출된다는 것을 발견했고, 앞으로도 마찬가지일 것이라는 가정 하에 최적화된 거예요.

파란색 코드는 메서드의 프롤로그와 에필로그(스택 프레임을 설정하고 복원)예요. 빨간색 코드는 인스턴스가 null이 아니고 이전에 본 타입 중 하나인지 확인하고, 그 외의 경우에는 느린 경로(slow path)를 호출해요. 굵은 글씨 코드가 필드를 실제로 로드하는 작업이고요.

이 최적화 코드는 과거와 미래의 동작이 다르면 오히려 더 느릴 수 있어요. getAge가 새로운 인스턴스(예를 들어 Snake)로 호출되면 추가 검사를 수행한 뒤에도 여전히 느린 경로로 빠지기 때문이죠.

Dart 1 생성 코드의 문제점

위에서 생성된 코드의 구조는 Chrome의 JavaScript 엔진인 V8이 거의 동등한 JavaScript/TypeScript/Flow 프로그램에 대해 만들어내는 코드와 매우 비슷해요. 이 방식(과 그에 따른 생성 코드)은 많은 시나리오에서 좋은 성능을 낼 수 있지만, 크기와 메모리 사용량에 민감한 모바일 기기를 포함해 더 넓은 범위의 클라이언트 플랫폼(특히 Flutter)을 대상으로 하기 시작하면서 더는 적합하지 않았어요.

  • 첫째, 클라이언트 측 컴파일 비용이 Dart 애플리케이션의 전체 풋프린트를 늘렸어요.
  • 둘째, 2단계 추측(speculative) 컴파일 비용이 애플리케이션 시작에 악영향을 줬어요.
  • 셋째, iOS에서는 JIT(just-in-time) 컴파일이 허용되지 않아요. 적어도 일부 대상에는 다른 전략이 필요했죠.

그래서 AOT(ahead-of-time) 컴파일 방식으로 전환했지만, Dart 1에서는 결과적으로 코드가 훨씬 나빠졌어요. 정교한 전체 프로그램 분석을 해도, 특히 애플리케이션이 커질수록 컴파일 시점에 타입 정보를 항상 결정할 수 없었거든요. 게다가 추측의 비용(위의 빨간 코드)은 전체 애플리케이션이 미리 컴파일되면 감당하기 어려워졌어요.

Dart 2: Sound 타입

Dart 2에서 soundness를 도입하면서 타입 정보를 바탕으로 안전하게 컴파일할 수 있게 됐고, 성능을 위한 프로파일링 의존도도 줄었어요. Dart 2에서는 단 한 번의 AOT 컴파일로 x64에서 10개의 명령어를 생성해요. 이 코드는 여전히 null 검사(빨간 부분)를 수행하고, null이 발견되면 헬퍼 메서드를 호출해요.

Dart 2.12: Sound null safety

sound null safety를 사용하면 타입 시스템이 더 풍부해지고, 컴파일러가 그 이점을 활용할 수 있어요. 컴파일러는 (이제) non-nullable인 타입을 안전하게 신뢰하고 위의 빨간 코드를 제거할 수 있고요. Dart 2.12 베타에서는 3개 더 적은 명령어를 생성했어요.

사실 코드가 단순해질수록 프롤로그와 에필로그도 더 간소화할 수 있었어요. 곧 나올 안정 버전에서는 이 예시 메서드에 대해 단 3개의 명령어만 생성할 거예요.

sound null safety 덕분에 이 메서드의 생성 코드를 본질(필드 로드 하나)로 줄일 수 있어요. 실제로 이 메서드에 대한 호출은 항상 인라인되죠. 인라인이 성능과 코드 크기 양쪽에 이득이라는 것을 컴파일러가 쉽게 알 수 있기 때문이에요. 런타임 검사와 보상(compensation) 코드가 더는 필요 없어요. 무거운 작업의 상당 부분이 컴파일 시점으로 옮겨갔어요. 클라이언트 측 컴파일의 시작·메모리 오버헤드도 더 필요 없고요. 그 결과 사용자는 더 작고 빠른 코드를 얻게 됩니다.

직접 해보세요!

null safety를 직접 시도해 보시길 권해요. 지금 베타 채널의 Dart 2.12에서 사용할 수 있어요. 업스트림 의존성을 마이그레이션하고 나면 자신의 패키지와 애플리케이션도 마이그레이션할 수 있어요. 여기 예시가 보여주듯이, 변경할 내용이 너무 많지 않을 수도 있어요.

null safety의 성능 이점을 얻으려면 애플리케이션을 완전히 마이그레이션해야 한다는 점을 기억하세요. 완전히 마이그레이션하고 나면 컴파일러가 자동으로 null safety를 활용해 더 좋고 작은 코드를 생성합니다.

참고: 전체 코드

다음은 이 글의 모든 코드를 생성하기 위해 컴파일한 전체 Dart 코드예요. 여기서 예시는 인위적이지만, 클래스 계층에 필드가 있는 이 패턴은 꽤 흔해요.

int N = 1000000;

class Animal {
  int age = 0;
}

class Cat extends Animal {}

class Dog extends Animal {}

class Snake extends Animal {}

class Hamster extends Animal {}

List<Animal> _animals = [
  new Cat()..age = 1,
  new Hamster()..age = 2,
  new Dog()..age = 3
];
List<Animal> listOfA = [];
void init() {
  for (int i = 0; i < N; ++i) {
    listOfA.add(_animals[i % _animals.length]);
  }
}

int sum() {
  int k = 0;
  for (int i = 0; i < N; ++i) {
    k += getAge(listOfA[i]);
  }
  return k;
}

@pragma('vm:never-inline')
int getAge(Animal a) {
  return a.age;
}

void main() {
  init();
  print(sum());
  print(getAge(listOfA[0]));
}

더 알아보기

  • Null safety — Dart 3의 sound null safety 전체 개요.
  • Dart 타입 시스템 — soundness의 의미와 타입 승격(type promotion)에 대한 자세한 내용.