esbuild는 왜 빠른가?(번역)

이 글은 esbuild의 공식문서에서 설명하는 esbuild의 강점을 번역한 글입니다. 이해하기 쉽게 일부 의역이 들어간 부분이 존재합니다.

https://esbuild.github.io/faq/

1. Go언어를 기반으로 작성되었고 네이티브 코드 방식으로 컴파일 합니다

대부분의 번들러는 JIT 컴파일 방식인 자바스크립트로 작성되었습니다. 이러한 방식은 command-line 응용 프로그램(기존의 번들러와 같은)에서는 최악의 성능을 보입니다. 번들러를 실행할 때마다 자바스크립트 가상 머신이 최적화된 힌트가 하나도 없는채로 번들러의 코드를 수행하기 때문입니다.

그래서 이러한 차이로 인한 속도를 비교하자면, 노드(자바스크립트)기반의 번들러들은 자바스크립트를 번들링 하기 위해 번들러의 코드를 우선 분석하는동안 esbuild는 이미 그 과정을 끝내고 당신의 자바스크립트 코드를 파싱할 것입니다. 그리고 노드가 번들러 코드 파싱을 끝내고 번들링을 시작하기도 전에 esbuild는 이미 번들링까지 마치고 종료되었을 것입니다.

게다가 Go는 병렬처리를 위해 핵심부터 설계 되었지만 자바스크립트는 그렇지 않습니다. Go는 스레드간에 메모리를 공유하지만 자바스크립트는 스레드간의 데이터를 직렬화 해야합니다.

Go와 자바스크립트는 병렬적인 가비지 컬렉터를 가지고 있지만, GO의 heap영역은 모든 스레드가 공유하고 자바스크립트의 heap영역은 각각의 스레드마다 독립적으로 가집니다. 그래서 자바스크립트는 추측컨데 CPU 코어의 절반이 나머지 절반의 가비지 컬렉터를 위해 작동하느라 worker 스레드들의 병렬 처리량이 반으로 줄어들 것입니다.

2. 병렬처리가 많이 사용됩니다

esbuild 내부 알고리즘을 살펴보면 가능한 한 많은 CPU 코어들을 최대한으로 사용하도록 세밀하게 설계 되어있습니다. 여기에서 수행하는 작업들은 크게 파싱, 연결(linking), 코드 생성 단계로 나눠집니다. 그중 파싱과 코드 생성 단계가 대부분의 작업을 차지하며 병렬 처리의 대부분입니다.(연결 작업은 대부분 연속적인 작업이라 병렬처리와 관련이 적습니다.)

여기서 병렬처리가 중요한 이유는 모든 스레드들이 메모리를 공유하기 때문에 번들링 작업을 쉽게 공유할 수 있기 때문입니다. 예를 들어, 각각의 CPU들이 서로 다른 진입점들을 번들링할 때, 이미 작업했던 동일한 라이브러리를 import 해야하는 경우 공유된 번들링된 작업을 참조하기만 하면 됩니다. 그래서 많은 코어를 가지고 있는 현대 컴퓨터들에게 병렬처리는 중요합니다.

3. esbuild의 모든 코드는 기초부터 작성 되었습니다

서드파티 라이브러리들을 사용하는 대신에 모든 것을 직접 작성하면 성능상 많은 이점이 있습니다. 직접 작성한다면 시작부터 성능을 염두에 두고 일관적인 데이터 구조를 사용하고 값비싼 변환과정들을 피할 수 있습니다. 그리고 필요할 때마다 광범위한 아키텍처를 변경할 수 있습니다. 물론 일이 많아진다는 것이 단점이 됩니다.

예를 들어, 많은 번들러들이 공식 TypeScript 컴파일러를 파서로 사용합니다. 하지만 TypeScript는 TypeScript 컴파일러 팀의 목표를 위해 만들어졌고 성능을 최고의 우선순위로 두지 않습니다. 그들의 코드는 메가모르픽 객체 형태동적 속성 접근을 상당히 많이 사용합니다.(두 방식 모두 자바스크립트의 속도를 저하시키는 것으로 잘 알려져 있습니다.)

그리고 TypeScript 파서는 타입 체커가 비활성화 되어 있어도 계속 타입 체커를 실행하는 것으로 나타납니다. esbuild의 커스텀한 Typescript 파서는 이러한 문제들이 없습니다.

4. 메모리가 효율적으로 사용됩니다

컴파일러들의 이상적인 복잡도는 입력 길이 내에서 O(n)입니다. 따라서 많은 데이터를 처리하는 경우에, 메모리 접근 속도가 성능에 큰 영향을 미칠 수 있습니다. 데이터를 넘기는 데 필요한 pass들이 적을수록(또는 데이터를 변환하는데 필요한 다른 표현들이 적을수록) 컴파일러의 속도는 더 빨라질 것입니다.

예를 들어, esbuild는 자바스크립트 AST(Abstract Syntax Tree)를 오직 3번만 사용합니다.

  1. symbol들을 선언, scope 설정, 파싱, 어휘 분석을 위한 pass
  2. 바인딩 symbol들, 구문 최소화, JSX,TS 문법들을 JS로 변환, ESNEXT 문법을 ES2015로 변환하기 위한 pass
  3. 식별자 최소화, 공백 최소화, 코드 생성 및 소스 맵 생성을 위한 pass

AST에 대한 설명 링크입니다. https://gyujincho.github.io/2018-06-19/AST-for-JS-devlopers

따라서 CPU 캐시의 사용량이 많은 동안 AST 데이터의 재사용이 극대화 됩니다. 다른 번들러들은 이러한 단계를 끼워서 수행하지 않고 별도의 pass 들로 수행합니다. 또한 이러한 번들러들은 데이터 표현들 간에 여러 라이브러리들을 붙여서 함께 변환할 수 있기 때문에 메모리를 더 많이 사용하고 작업 속도가 느려집니다.(예를 들어, string -> TS -> JS -> string 으로 변환 후에, string -> JS -> 오래된 JS -> string 으로 변환 하고, string -> JS -> 압축된 JS -> string 로 변환)

Go의 또다른 장점은 메모리에 데이터를 밀도있게 저장할 수 있다는 것인데, 이 점은 메모리를 적게 사용하고 CPU 캐시에 더 많이 적재할 수 있게 해줍니다. 모든 객체 영역들은 타입을 가지며 영역들이 타이트하게 채워져 있습니다. 예를 들어 몇몇 boolean flag 들은 각각 1바이트만 사용합니다.

Go는 또한 가치 의미론을 가지고 있으며 한 객체를 다른 객체에 직접적으로 내장할 수 있으므로 다른 할당 없이 “비용 없이” 제공됩니다. 자바스크립트는 이러한 기능이 없으며 JIT 오버헤드(예를 들어, 숨겨진 클래스 슬롯)와 비효율적인 표현들(예를 들어, 정수가 아닌 number들은 힙 영역에 포인터로 할당 되는 것들)과 같은 단점들이 존재합니다.


이러한 요인들을 각각 보면 단순한 속도 향상일 뿐이지만, 함께 사용하면 일반적으로 사용되는 다른 번들러들 보다 몇 배나 빠른 결과를 내는 번들러가 될 수 있습니다.