무엇이 WebAssembly를 빠르게 만드나?

이글은 WebAssembly와 그 실행성능에 관한 시리즈의 5번째 글입니다. 아직 다른 글들을 읽지 않았다면 처음부터 읽기를 권합니다.

지난 글에서, 저는 WebAssembly 또는 JavaScript 프로그래밍이 어느 하나를 취사선택해야 하는 일이 아님을 설명했습니다. 우리는 많은 개발자들이 전적으로 WebAssembly를 이용해서 코드를 만드리라고 생각하지 않습니다.

그래서 개발자들은 자신의 어플리케이션을 개발할 때 WebAssembly와 JavaScript 중에서 하나를 선택할 필요가 없습니다. 대신, 우리는 개발자들이 점진적으로 자신의 JavaSript 코드 중 일부를 WebAssembly로 대체해 나가리라고 기대합니다.

예를 들어, React 개발팀이라면 자신의 reconciler 코드 (즉 virtual DOM 코드)를 WebAssembly 버전으로 바꿀 수 있을 것입니다. React를 이용하는 사람들은 아무것도 할 필요가 없습니다… 그래도 React를 이용하는 사람들의 앱은 이전과 똑같이 동작할 것입니다. 다만 WebAssembly 덕분에 개선된 장점을 누리게 될 것입니다.

(가령 React 개발팀 같은) 개발자들이 자신들의 코드를 WebAssembly 코드로 대체하려고 하는 이유는 WebAssembly가 더 빠르기 때문입니다. 그런데 무엇이 WebAssembly를 더 빠르게 만드는 걸까요?

오늘날의 JavaScript 실행성능에 대하여

JavaScript와 WebAssembly의 실행성능 차이를 이해하려면, JS 엔진이 하는 일을 이해할 필요가 있습니다.

아래 그림은 오늘날의 어플리케이션 실행성능이 어떻게 결정되는지 개략적으로 설명합니다.

JS 엔진이 아래 나열된 작업을 수행하는데 걸리는 시간은 해당 페이지에 적용된 JavaScript 코드에 따라 달라집니다. 이 그림은 정확한 수치를 제공하려는 것이 아닙니다. 대신, 동일한 작업을 수행하는데 있어 JS와 WebAssembly가 얼마나 성능 차이를 보이는지 대략적인 비교를 제공하려는 것입니다.

Diagram showing 5 categories of work in current JS engines

각 막대는 특정 작업을 수행하는데 소용되는 시간을 표현합니다.

  • 파싱— 소스코드를 인터프리터가 실행 가능한 형태로 변환하는데 소요되는 시간.
  • 컴파일링 + 옵티마이징— 베이스라인(baseline) 컴파일러와 옵티마이징 컴파일러에서 소요되는 시간. 옵티마이징 컴파일러의 일부 작업은 메인 쓰레드에서 처리되지 않습니다. 그래서 여기 포함되지 않습니다.
  • 리-옵티마이징(Re-optimizing)— JIT이 내린 가정이 틀렸을 때 이를 재조정하기 위해 소요되는 시간. 코드를 리-옵티마이징 하는데 소요되는 시간과 이미 최적화된 코드를 베이스라인 코드로 복구하는데 소요되는 시간을 모두 포함합니다.
  • 실행 (Execution)— 코드 실행에 소요되는 시간.
  • 가비지 컬렉션— 메모리를 청소하는데 소용되는 시간.

한가지 중요하게 일러둘 사항이 있습니다. 이 작업들은 각각 한 덩어리로 실행되거나 특정 순서에 따라 실행되지 않습니다. 대신, 이 작업들은 번갈아가며 실행될 것입니다. 파싱 작업이 조금 처리되고, 그다음에 실행 작업이, 그다음에 컴파일링 작업이, 그 다음에 다시 파싱 작업이, 그리고 다시 실행 작업이 일어나는 식으로 말입니다.

이렇게 작업을 분할해서 얻은 실행성능은 초기 JavaScript의 실행성능과 비교할 때 비약적으로 발전한 것입니다. 초기 JavaScript의 실행성능을 그림으로 표현하면 대략 아래와 같습니다.

Diagram showing 3 categories of work in past JS engines (parse, execute, and garbage collection) with times being much longer than previous diagram

처음에, 즉 단지 JavaScript 코드를 실행시키는 인터프리터 뿐이었을 때는 실행시간이 상당히 느렸습니다. 이것이 JIT이 도입되면서 극적으로 빨라졌습니다.

JIT의 반대급부는 코드를 모니터링하고 컴파일링하는데 소요되는 오버헤드입니다. 만약 JavaScript 개발자들이 오래전과 비슷한 코드만 만든다면, 파싱과 컴파일링에 소요되는 시간은 미미할 것입니다. 하지만 실행성능이 개선되면서 개발자들은 더 큰 규모의 JavaScript 어플리케이션들을 만들기 시작했습니다.

이것은 아직도 더 많이 개선해야 한다는 것을 의미합니다.

WebAssembly와 비교하자면?

전형적인 웹 어플리케이션과 WebAssembly를 비교하면 대략 다음과 같습니다.

Diagram showing 3 categories of work in WebAssembly (decode, compile + optimize, and execute) with times being much shorter than either of the previous diagrams

브라우저마다 각 작업 단계를 처리하는 방식에 약간 차이점이 존재합니다. 저는 SpiderMonkey를 기준으로 설명하겠습니다.

가져오기 (Fetching)

그림에 표시되지 않았지만, 서버에서 파일을 가져오는데 시간이 소요됩니다.

WebAssembly의 코드 크기가 JavaScript 보다 작기 때문에, 더 빨리 가져올 수 있습니다. 압축 알고리즘을 적용하면 JavaScript의 코드 크기를 많이 줄일 수 있지만, 그래도 여전히 WebAssembly 바이너리 코드를 압축한 쪽의 크기가 더 작습니다.

이는 클라이언트가 서버로부터 코드를 전송 받는데 소요되는 시간이 더 작다는 것을 의미합니다. 느린 전송망을 사용할 경우 이 차이는 더욱 중요하게 드러납니다.

파싱 (Parsing)

일단 코드가 브라우저에 도달하면, JavaScript 소스 코드는 Abstract Syntax Tree 형태로 파싱됩니다.

브라우저는 파싱 작업을 최대한 지연시켜 처리합니다. 당장 실행에 필요한 코드만 파싱하고 아직 실행할 필요가 없는 코드에는 식별 가능한 표시만 남겨둡니다.

파싱을 거치면 AST는 (바이트코드라고 불리는) 중간형태의 코드로 변환됩니다. 바이트코드는 JS 엔진에 따라 고유한 형태를 가집니다.

반면, WebAssembly는 이런 변환 과정을 거칠 필요가 없습니다. 왜냐하면 WebAssembly 자체가 이미 중간형태의 코드이기 때문입니다. WebAssembly의 경우 디코딩 작업과 오류 없이 전송됐는지 확인하는 검증 작업만 필요로 합니다.

Diagram comparing parsing in current JS engine with decoding in WebAssembly, which is shorter

컴파일링 + 옵티마이징

제가 JIT에 대해 쓴 글에서 설명한 것처럼, JavaScript 코드는 코드를 실행하는 도중에 컴파일됩니다. 실행 시간에 사용되는 데이터의 타입에 따라 동일한 코드가 여러 버전으로 컴파일될 수 있습니다.

WebAssembly의 코드 컴파일링 방식은 브라우저의 종류에 따라 다릅니다. 어떤 브라우저는 WebAssembly 코드를 실행시키기 전에 베이스라인 컴파일링을 거치는 방식을 사용하고, 또 어떤 브라우저는 JIT 방식을 사용합니다.

어떤 방식을 사용하더라도, WebAssembly 코드는 처음부터 머신 코드와 아주 유사한 상태에서 시작합니다. 예를 들어, 타입 정보가 프로그램에 포함되어 있습니다. 이 때문에 처리 속도가 빨리지는데 그 이유는 다음과 같습니다.

  1. 컴파일러가 최적화 코드를 컴파일하려는 목적으로 실행 중에 어떤 데이터 타입이 사용되고 있는지 관찰할 필요가 없습니다.
  2. 컴파일러가 관찰된 데이터 타입에 따라 동일한 코드를 여러 버전으로 컴파일할 필요가 없습니다.
  3. LLVM 수준에서 이미 더 많은 최적화 작업이 완료된 상태입니다. 그래서 WebAssembly 코드를 컴파일하고 최적화 하는데 많은 작업이 필요하지 않습니다.

Diagram comparing compiling + optimizing, with WebAssembly being shorter

리옵티마이징 (Reoptimizing)

때때로 JIT는 컴파일된 최적화 코드를 버리고 다시 최적화 작업을 수행해야 하는 때가 있습니다.

JIT이 코드 실행 상황을 관찰해서 내렸던 가정이 틀렸을 경우 이런 일이 발생합니다. 예를 들어, 어떤 루프에 사용되는 변수의 데이터 타입이 이전 루프 작업과 달라졌을 때, 또는 프로토타입 체인에 새로운 함수가 추가됐을 때, 컴파일된 최적화 코드를 버리는 일(deoptimization)이 발생합니다.

최적화 코드를 버리는 일에는 이중의 비용이 소요됩니다. 첫째로, 최적화 코드를 버리고 베이스라인 버전으로 되돌아 가는데 시간이 소요됩니다. 둘째로, 해당 함수가 여전히 자주 호출된다면, JIT은 해당 코드를 다시 최적화하기 위해 다시 컴파일러를 호출하려고 할 것이고, 그래서 다시 컴파일 작업이 수행되는 시간이 소요됩니다.

WebAssembly에서는 이런 일이 발생하지 않습니다. 코드 실행 시간에 JIT이 데이터 타입을 알아낼 필요가 없기 때문입니다. 이는 리옵티마이징 단계가 필요 없음을 의미합니다.

Diagram showing that reoptimization happens in JS, but is not required for WebAssembly

실행 (Executing)

우리는 실행성능이 좋도록 JavaScript 코드를 작성할 수 있습니다. 그러려면 우선 JIT의 코드 최적화 방식을 알아야 합니다. 예를 들어 JIT 관련 기사에 설명된 것처럼 컴파일러가 데이터 타입을 잘 처리하게 만드는 코드 작성법을 알아야 합니다.

하지만, 대부분의 개발자들은 JIT의 내부 동작 방식을 잘 알지 못합니다. JIT 내부를 이해하는 개발자라 하더라도 JIT의 최적화 방식을 적중시키는 코드를 만드는 것은 어렵습니다. 사람에 읽기 쉬운 코드를 만들기 위해 사용되는 많은 코딩 패턴(예를 들어 공통 작업을 추상화해서 다양한 데이터 타입을 처리하는 코딩 패턴)들이 오히려 컴파일러가 최적화 코드를 만드는 작업에는 방해가 됩니다.

또, 브라우저의 종류에 따라 JIT이 사용하는 최적화 방식이 다릅니다. 그래서 어떤 브라우저에서는 효율적인 코딩 방식이 다른 브라우저에서는 효율적이지 않을 수 있습니다.

이런 이유로, WebAssembly 코드가 더 빠르게 실행됩니다. JIT이 JavaScript 코드를 최적화시키기 위해 사용하는 많은 일(예를 들어 타입을 특정하는 일)들이 WebAssembly에서는 필요하지 않습니다.

더구나, WebAssembly는 원래 컴파일러 타겟으로 설계됐습니다. 이는 WebAssembly가 컴파일러의 작업 결과물을 정의하기 위한 목적으로 설계됐다는 뜻입니다. 사람이 작성하는 코드를 정의하기 위해 설계된 것이 아닙니다.

프로그래머는 코드를 만들 때 WebAssembly를 직접 사용하지 않습니다. WebAssembly는 기계에게 보다 적합한 명령어 집합을 제공할 뿐입니다. 당신이 작성하는 코드의 내용에 따라 다르겠지만 WebAssembly가 제공하는 명령어 집합을 사용하는 쪽이 모든 브라우저에서 10% 에서 800% 빠르게 실행됩니다.

Diagram comparing execution, with WebAssembly being shorter

가비지 컬렉션

JavaScript 개발자는 사용되지 않는 오래된 변수를 메모리에서 제거하는 일에 대해 걱정하지 않습니다. JS 엔진의 가비지 컬렉터가 메모리 관리를 대신해주기 때문입니다.

그런데 이것이 문제가 될 때가 있습니다. 바로 예측 가능한 실행 성능이 필요한 때입니다. 우리는 가비지 컬렉터가 언제 실행될지 관여할 수 없습니다. 그래서 원하지 않는 순간에 가비지 컬렉터가 동작할 수 있습니다. 대부분의 브라우저들이 가비지 컬렉터가 동작하는 타이밍을 적절히 관리하지만, 여전히 당신의 코드가 실행되는 도중에 가비지 컬렉터가 동작할 수 있으며 그것은 분명히 오버헤드입니다.

WebAssembly의 경우, 적어도 지금은 가비지 컬렉터를 전혀 지원하지 않습니다. 메모리 관리는 (C/C++ 랭귀지 처럼) 프로그래머가 직접해야 합니다. 이 때문에 개발자 입장에서는 프로그래밍이 좀더 어렵지만, 이 덕분에 일관된 실행성능을 보장할 수 있습니다.

Diagram showing that garbage collection happens in JS, but is not required for WebAssembly

결론

WebAssembly는 JavaScript보다 빨리 실행됩니다. 그 이유는 다음과 같습니다.

  • 코드를 가져오는 시간이 짧습니다. WebAssembly 코드가 JavaScript 코드보다 작기 때문입니다. 압축된 상황에서도 그렇습니다.
  • WebAssembly 코드 디코딩 시간이 JavaScript 코드 컴파일 시간보다 짧습니다.
  • 컴파일링과 옵티마이징 시간이 짧습니다. 왜냐하면 WebAssembly 코드는 JavaScript 코드보다 머신 코드에 가깝기 때문입니다. 그리고 WebAssembly 코드는 서버에서 이미 최적화된 상태로 전달됩니다.
  • 리옵티마이징 작업이 필요하지 않습니다. 왜냐하면 WebAssembly 코드에는 타입 정보를 비롯한 실행 정보가 이미 포함되어 있기 때문입니다. JS 엔진이 JavaScript 코드를 최적화할 때처럼 실행 상황을 관찰하고 최적화 방안을 추측하는 작업이 필요하지 않습니다.
  • 더 빨리 실행됩니다. 왜냐하면 개발자가 실행 성능을 높이기 위해 주의해야 하는 트릭과 꼼수가 거의 없기 때문입니다. 더구나 WebAssembly의 명령어 집합이 좀더 머신 친화적입니다.
  • 개발자가 메모리를 직접 관리해야 하기 때문에 가비지 컬렉션이 필요 없습니다.

이것이 동일한 작업을 처리할 때 WebAssembly의 실행성능이 JavaScript의 실행성능을 압도하는 이유입니다.

WebAssembly의 실행성능이 기대에 미치지 못하는 경우가 있습니다. 그리고 그런 경우에도 WebAssembly의 실행성능을 높이기 위한 변화들이 만들어지고 있습니다. 다음 글에서 그런 변화들을 소개할 예정입니다.

이 글은 Lin Clark 이 쓴 What makes WebAssembly fast? 의 한국어 번역본입니다.

작성자: ingeeKim

"누구에게나 평등하고 자유로운 웹"에 공감하는 직장인.

ingeeKim가 작성한 문서들…


댓글이 없습니다.

댓글 쓰기