WebAssembly에서 메모리 최대 4GB 까지 사용하기


원글: Andreas Haas, Jakob Kummerow, and Alon Zakai - Up to 4GB of memory in WebAssembly 라이선스: CC BY 3.0

소개

Chrome과 Emscripten의 노력 덕분에, 이제 웹 어셈블리 애플리케이션에서 4GB의 메모리를 사용할 수 있게 되었다. 이전에는 2GB까지만 사용 가능했다. 메모리 사용량에 제한이 있다는 사실이 이상하게 다가오겠지만, 대부분 512MB 또는 1GB의 메모리를 사용하는 작업이 필요하지 않았다. 하지만 이번에 2GB 제한에서 4GB로 늘어나면서 브라우저와 툴 체인에 조금 특별한 일이 일어났다. 어떤 일이었는지 설명해 보겠다.

32 비트

시작하기 전에 알아두어야 할 내용이 하나 있는데, 새로운 4GB 메모리 제한은 32비트 포인터 환경에서 사용 가능한 용량이다. 이는 현재 웹 어셈블리가 지원하는 환경으로 LLVM등에서는 "wasm32"로 알려져 있다. 64비트 포인터를 사용하며 1,600만 테라바이트 이상의 메모리를 사용할 수 있는(!) "wasm64"(wasm 명세의 "memory64")는 아직 진행 중이지만, 그때 까지는 4GB가 웹 어셈블리에서 사용 가능한 최대 용량이다.

32비트 포인터는 원래 4GB 용량을 지원한다. 그런데, 왜 그동안 2GB밖에 사용하지 못한 것일까? 여러 이유가 있지만, 브라우저와 툴체인 모두 그럴 수밖에 없던 이유가 있다. 우선 브라우저부터 살펴보자.

Chrome/V8의 처리

원리만을 따지고 보면 V8은 간단한 수정만 하면 될 것처럼 보인다. 웹 어셈블리 함수용으로 생성된 모든 코드와 메모리 관리 코드가 메모리 인덱스와 길이에 부호 없는 32비트 정수를 사용하는지 확인하는 것이다. 하지만 실제로는 더 많은 수정이 필요하다. 웹 어셈블리 메모리를 자바스크립트의 ArrayBuffer로 내보낼 수도 있는데, 이렇게 되면 ArrayBuffer, TypedArray를 사용하는 모든 웹 API의 구현도 변경해야 한다.

우선 V8이 TypedArray 인덱스와 길이에 Sims(31비트 부호 있는 정수)를 사용했기 때문에, 실상 (2^30)-1 즉 대략 1GB 정도의 크기밖에 가지지 못했다. 게다가, 32비트 정수로 모든 것을 바꾸더라도 4GB를 저장하기엔 부족했다. 이것에 대해 부연 설명하자면 10진수로 두 자리(0부터 99까지의 정수)를 가진 수 100개가 있지만, "100" 자체는 3자리 숫자이다. 마찬가지로 4GB 는 32비트 주소로 처리될 수 있지만, 4GB 자체는 33비트 숫자다. 따라서 항상 가능한 크기보다 조금 더 작은 한도까지만 사용할 수 있지만, 어쨌든 모든 TypedArray 코드를 수정해야 했기 때문에 미래에 사용하게 될지 모를 더 큰 메모리 한도에 대비하기 위한 준비도 했다. 64비트 길이의 정수 타입, 혹은 자바스크립트에 필요하게 될 길이를 사용할 수 있게 TypedArray 인덱스와 길이를 처리하는 모든 코드를 수정했다. 그 작업에 대한 효과로, 지금 당장 wasm64에 더 큰 메모리를 지원할 수도 있게 되었다.

두 번째 어려움은 객체의 구현 중 하나인 명명된 속성이 자바스크립트 배열 요소에 적용되었을 때의 특별 케이스를 처리하는 것이었다. (이것은 자바스크립트 명세와 관련된 기술적인 이슈로, 자세한 내용은 다 알지 않아도 괜찮다) 다음 예시를 보자.

console.log(array[5_000_000_000]);

array가 자바스크립트 객체거나 배열이라면, array[5_000_000_000]는 문자열 기반 속성 탐색으로 처리될 것이다. 런타임은 문자열 이름 속성 "5000000000"을 찾을 것이다. 해당 속성이 없다면 프로토타입 체인을 따라서 해당 속성을 찾다가 체인의 끝에서 결국 undefined를 반환하게 될 것이다. 하지만 array나 프로토타입 체인의 객체가 TypedArray인 경우에 런타임은 인덱스 5,000,000,000에서 인덱스 된 요소를 찾아보거나 인덱스 범위를 벗어난 경우 즉시 undefined를 반환할 것이다.

다시 말해 TypedArray의 규칙은 일반 배열과는 다르며, 그 차이는 큰 규모의 인덱스 조작에서 드러난다. 작은 TypedArray만 허용한다면 구현은 상대적으로 쉬워질 것이다. 특히 속성 키를 한 번만 살펴보면 인덱싱, 혹은 명명된 탐색을 할 것인지 결정할 수 있게 된다. 더 큰 TypedArray를 허용하려면 프로토타입 체인을 따라갈 때 반복적으로 이런 구분작업을 해야 한다. 따라서 반복적인 작업과 오버헤드로 기존 자바스크립트 코드가 느려지지 않도록 캐싱을 적절히 사용해야 한다.

툴 체인의 처리

툴 체인 측에서도 웹 어셈블리의 컴파일 된 코드뿐만 아니라 자바스크립트 지원 코드에서도 잘 동작하도록 해야 했다. 가장 큰 문제는 Emscripten이 메모리에 접근하는 코드를 항상 다음과 같이 작성한다는 것이다.

HEAP32[(ptr + offset) >> 2]

이 코드는 ptr + offset주소에서부터 부호 있는 정수로 32비트(4바이트)를 읽는다. HEAP32는 Int32Array로 작동한다. 따라서 각 배열의 인덱스에는 4바이트가 있다는 것을 알 수 있다. 그러므로 인덱스를 얻기 위해서는 ptr + offset을 4로 나눌 필요가 있다. 이것이 >>2가 하는 일이다.

>>는 부호 있는 연산이라는 게 문제다! 만약 주소가 2GB보다 같거나 크다면 음수로 오버플로우 될 것이다.

// 2GB 미만이므로 괜찮다. 536870911이 출력된다.
console.log((2 * 1024 * 1024 * 1024 - 4) >> 2);
// 2GB 초과로 -536870912가 출력된다. :(
console.log((2 * 1024 * 1024 * 1024) >> 2);

이 때는 >>> 부호 없는 이동(unsigned shift)를 하면 된다.

// 이제야 정상적으로 536870912가 출력된다!
console.log((2 * 1024 * 1024 * 1024) >>> 2);

Emscripten은 컴파일 시점에 2GB 이상의 메모리를 사용할 수 있는지 알 수 있다(컴파일 시 사용하는 플래그에 달려있다. 잠시 후에 설명할 예정). 2GB보다 큰 주소를 사용할 수 있도록 플래그를 설정했다면, 컴파일러는 >> 대신 >>>를 사용한다. 그리고 위의 예제에서처럼 HEAP32 뿐만 아니라 .subArray()와 , .copyWithin()과 같은 메모리에 접근하는 코드를 자동으로 재작성한다. 다시 말해 부호 있는 포인터가 아닌 부호 없는 포인터를 사용하도록 바꾸는 것이다.

이런 전환은 코드 크기를 조금씩 키운다. 각 shift마다 한 개의 문자가 추가되는 것이다. 이게 바로 2GB보다 더 큰 메모리 주소가 필요하지 않을 때는 이런 일을 하지 않는 이유이다. 비록 늘어난 크기는 일반적으로 1%보다 더 작은 정도지만, 그 용량은 실제로는 필요하지 않은 것에 의해 늘어난 것이기도 하고, 용량이 늘어나지 않을 경우를 쉽게 파악할 수 있기 때문이다. 그리고 다수의 최적화 코드들이 추가된다.

자바스크립트 지원 코드에서 드물게 이슈가 발생할 수도 있다. 일반적인 메모리 접근은 앞서 설명한 대로 자동으로 처리되지만, 부호 있는 포인터와 부호 없는 포인터(2GB보다 같거나 큰 주소의)를 수동으로 비교하는 등의 작업을 수행하면, false를 반환한다. 이런 문제를 찾기 위해 우리는 Emscripten의 자바스크립트를 검사했으며 특정 모드에서 모든 것이 2GB 이상의 주소에 있는 상황에서 테스트 수트를 실행했다. (여러분이 자신만의 자바스크립트 지원 코드를 작성했고, 일반적인 메모리 접근이 아닌, 포인터를 사용하여 수동으로 메모리에 접근한다면 우리와 비슷한 수정이 필요할 것이다.)

실습해보기

테스트를 위해 최신 Emscripten 릴리스 또는 1.39.15 이상의 버전이 필요하다. 그리고 다음과 같은 플래그를 추가해서 빌드하면 된다.

emcc -s ALLOW_MEMORY_GROWTH -s MAXIMUM_MEMORY=4GB

이렇게 하면 메모리를 늘릴 수 있다. 그리고 프로그램에서 최대 4GB에 달하는 메모리를 할당 할 수 있다. 기본적으로는 2GB까지만 할당할 수 있지만, 2~4GB의 메모리를 사용하려면 명시적으로 이렇게 옵션을 넘겨주어야 한다는 것을 알아두자. (이를 통해 위에서 언급한 >>> 대신 >>를 사용하는 더 작은 코드를 얻을 수 있다)

크롬 M83 (beta 버전) 이상에서 테스트해야 한다. 사용 중에 문제를 찾는다면 어떤 것이라도 좋으니 우리에게 보고해주길 부탁한다.

결론

4GB 메모리 지원은 웹을 네이티브 플랫폼처럼 쓸 수 있게 해주는 하나의 단계다. 32 비트 프로그램이 본래의 용량을 정상적으로 사용할 수 있게 된 것이다. 이 단계에서는 완전히 새로운 단계의 애플리케이션으로 바뀌지 않겠지만, 방대한 게임 레벨이나 고용량 콘텐츠를 그래픽 편집기에서 수정하는 등의 고급 경험을 웹에서도 가능하게 할 수 있다.

앞서 말했듯이 64비트 메모리 지원도 계획되어 있으며, 4GB 이상을 사용할 수 있다. 하지만 wasm64는 64비트 네이티브 플랫폼과 동일한 단점을 가진다. 바로 메모리를 두 배 더 소모한다는 것이다. 그래서 wasm32에서 4GB 지원이 중요한 의미를 가지는 이유이다. 이전과 같은 wasm 코드 크기를 가지고 두 배의 메모리를 사용할 수 있으니 말이다!

늘 그렇듯, 여러 브라우저에서 코드를 테스트해야 한다. 그리고 2~4GB도 큰 메모리 용량인 것을 명심해야 한다! 더 많이 사용해야 한다면 어쩔 수 없지만, 모든 사용자의 컴퓨터에 메모리가 많은 것은 아니니 아껴서 사용해야 한다. 가능한 작은 초기 메모리 용량으로 시작하고 필요한 경우에 확장하는 것이 좋다. 그리고 만약 메모리가 늘어나는 옵션을 주었다면 malloc() 실패를 우아하게 처리하자.


박정환, FE Development Lab2020.05.22Back to list