WebAssembly의 Memory64가 64비트 포인터를 도입했지만 대개 32비트보다 느린 이유와, 그럼에도 불구하고 언제 Memory64가 필요한지(4GB 이상 메모리) 및 향후 가능성을 설명한다.
Jan 15, 2025 • Ben Visness
오랜 세월이 흐른 끝에, WebAssembly의 Memory64 제안이 마침내 Firefox 134와 Chrome 133에서 모두 릴리스되었습니다. 요컨대, 이 제안은 WebAssembly에 64비트 포인터를 추가합니다.
대부분의 독자라면 이런 의문이 들 수 있습니다. “왜 WebAssembly는 처음부터 64비트가 아니었지?” 그렇습니다. 2025년인데도 WebAssembly는 이제서야 64비트 포인터를 추가했습니다. 64비트 기기가 대다수이고, RAM 8GB가 사실상 최소 사양으로 여겨지는 시대에 왜 이렇게 오래 걸렸을까요?
64비트 WebAssembly가 64비트 하드웨어에서 더 잘 실행될 거라고 생각하기 쉽지만, 안타깝게도 사실이 아닙니다. WebAssembly 앱은 32비트 모드보다 64비트 모드에서 더 느리게 실행되는 경향이 있습니다. 이 성능 페널티는 워크로드에 따라 달라지지만, 10% 수준부터 100%를 훌쩍 넘는 경우까지—포인터 크기만 바꿨을 뿐인데 2배 느려지는 경우도 있습니다.
이는 단순히 최적화가 부족해서가 아닙니다. 오히려 Memory64의 성능은 하드웨어, 운영체제, 그리고 WebAssembly 자체의 설계에 의해 제한됩니다.
Memory64가 왜 더 느린지 이해하려면, 먼저 WebAssembly가 메모리를 어떻게 표현하는지 이해해야 합니다.
프로그램을 WebAssembly로 컴파일하면 결과물은 WebAssembly 모듈이 됩니다. 모듈은 실행 파일과 유사하며, 프로그램을 부트스트랩하고 실행하는 데 필요한 모든 정보를 담고 있습니다. 예를 들면:
이들은 효율적인 바이너리 형식으로 인코딩되지만, WebAssembly에는 디버깅과 직접 작성에 쓰이는 공식 텍스트 문법도 있습니다. 이 글에서는 텍스트 문법을 사용합니다. WABT (wasm2wat)나 wasm-tools (wasm-tools print) 같은 도구를 사용하면 어떤 WebAssembly 모듈이든 텍스트 문법으로 변환할 수 있습니다.
아래는 메모리의 주소 16에 i32를 저장하고 불러올 수 있게 해주는 간단하지만 완전한 WebAssembly 모듈입니다.
wasm(module ;; Declare a memory with a size of 1 page (64KiB, or 65536 bytes) (memory 1) ;; Declare, and export, our store function (func (export "storeAt16") (param i32) i32.const 16 ;; push address 16 to the stack local.get 0 ;; get the i32 param and push it to the stack i32.store ;; store the value to the address ) ;; Declare, and export, our load function (func (export "loadFrom16") (result i32) i32.const 16 ;; push address 16 to the stack i32.load ;; load from the address ) )
이제 이 프로그램을 Memory64를 사용하도록 수정해 봅시다.
wasm(module ;; Declare an i64 memory with a size of 1 page (64KiB, or 65536 bytes) (memory i64 1) ;; Declare, and export, our store function (func (export "storeAt16") (param i32) i64.const 16 ;; push address 16 to the stack local.get 0 ;; get the i32 param and push it to the stack i32.store ;; store the value to the address ) ;; Declare, and export, our load function (func (export "loadFrom16") (result i32) i64.const 16 ;; push address 16 to the stack i32.load ;; load from the address ) )
메모리 선언에 i64가 포함되어 64비트 주소를 사용한다는 뜻이 되었습니다. 따라서 i32.const 16을 i64.const 16으로 바꿉니다. 끝입니다. 이것이 Memory64 제안의 거의 전부입니다.1
그런데 왜 이 작은 변경이 성능에 차이를 만들까요? WebAssembly 엔진이 실제로 메모리를 어떻게 구현하는지 알아야 합니다.
다행히 매우 단순합니다. 호스트(여기서는 브라우저)는 mmap이나 VirtualAlloc 같은 시스템 콜로 WebAssembly 모듈을 위한 메모리를 할당합니다. WebAssembly 코드는 그 영역 안에서 자유롭게 읽고 쓸 수 있으며, 호스트(브라우저)는 WebAssembly 주소(예: 16)가 할당된 메모리 내부의 올바른 주소로 변환되도록 보장합니다.
하지만 WebAssembly에는 중요한 제약이 있습니다. 메모리 범위를 벗어난 접근은 세그멘테이션 폴트(segfault)와 유사하게 트랩(trap) 되어야 합니다. 이를 보장하는 것은 호스트의 책임이며, 일반적으로 호스트는 경계 검사(bounds check) 로 이를 구현합니다. 즉, 각 메모리 접근마다 기계어 코드에 추가 명령을 삽입하는 방식입니다—모든 로드 전에 매번 다음을 실행하는 것과 같습니다.2
plaintextif (address >= memory.length) { trap(); }
이는 SpiderMonkey가 i32.load에 대해 생성하는 실제 x64 기계어 코드에서도 확인할 수 있습니다.3
asmmovq 0x08(%r14), %rax ;; load the size of memory from the instance (%r14) cmp %rax, %rdi ;; compare the address (%rdi) to the limit jb .load ;; if the address is ok, jump to the load ud2 ;; trap .load: movl (%r15,%rdi,1), %eax ;; load an i32 from memory (%r15 + %rdi)
이 명령들은 여러 비용을 초래합니다! CPU 사이클을 소모하는 것뿐 아니라, 메모리에서 추가 로드를 필요로 하고, 기계어 코드 크기를 증가시키며, 브랜치 프레딕터 자원도 차지합니다. 하지만 WebAssembly 코드의 보안성과 정확성을 보장하는 데는 필수적입니다.
그런데… 혹시 이것들을 완전히 제거할 방법이 있을까요?
32비트 정수의 최대값은 약 40억입니다. 따라서 32비트 포인터는 최대 4GB 메모리를 사용할 수 있습니다. 반면 64비트 정수의 최대값은 약 1,800경(18 sextillion)으로, 최대 18엑사바이트의 메모리를 주소 지정할 수 있습니다. 이는 엄청난 크기로, 오늘날 가장 고급 소비자용 기기의 메모리보다 수천만 배 큽니다. 실제로 이 차이가 너무 크기 때문에, 대부분의 “64비트” 기기는 현실적으로 48비트만 사용하며, 가상 주소에서 물리 주소로 매핑할 때 메모리 주소의 48비트만 사용합니다.4
48비트 메모리조차도 거대합니다. 가능한 최대 32비트 메모리보다 65,536배 더 큽니다. 즉, 기기에 물리 메모리가 수 GB뿐이더라도 각 프로세스는 281TB의 주소 공간(address space) 을 사용할 수 있습니다.
이는 64비트 기기에서는 주소 공간이 “싸다”는 뜻입니다. 원한다면 운영체제로부터 4GB의 주소 공간을 예약(reserve) 해 두어 나중에 쓰기 위해 비워둘 수 있습니다. 그 중 대부분을 실제로 사용하지 않더라도, 대부분의 시스템에서는 영향이 거의 없습니다.
브라우저는 이 사실을 어떻게 활용할까요? 모든 WebAssembly 모듈마다 4GB의 메모리를 예약합니다.
첫 번째 예제에서 우리는 64KB 크기의 32비트 메모리를 선언했습니다. 하지만 64비트 운영체제에서 이 예제를 실행하면 브라우저는 실제로 4GB를 예약합니다. 이 4GB 블록 중 첫 64KB는 읽기-쓰기 가능하고, 나머지 3.9999GB는 예약되어 있지만 접근할 수 없습니다.
모든 32비트 WebAssembly 모듈에 대해 4GB를 예약하면 범위를 벗어나는 것이 불가능해집니다. 가능한 가장 큰 포인터 값인 2^32-1은 그저 예약된 메모리 영역 안에 떨어지고 트랩이 발생합니다. 따라서 64비트 시스템에서 32비트 wasm을 실행할 때는 모든 경계 검사를 완전히 생략할 수 있습니다.5
이 최적화는 Memory64에는 불가능합니다. WebAssembly 주소 공간의 크기가 호스트 주소 공간의 크기와 동일해지기 때문입니다. 따라서 모든 접근에서 경계 검사 비용을 치러야 하고, 그 결과 Memory64는 느립니다.
Memory64를 써야 하는 이유는 딱 하나입니다. 정말로 4GB를 넘는 메모리가 필요할 때 입니다.
Memory64가 코드를 더 빠르게 만들거나 더 “현대적”으로 만들어주지는 않습니다. WebAssembly에서 64비트 포인터는 단지 더 많은 메모리를 주소 지정할 수 있게 해주며, 그 대가로 로드/스토어가 더 느려집니다.
시간이 지나면서 엔진들이 최적화를 더하면 성능 페널티는 줄어들 수 있습니다. 경계 검사 전략이 개선될 수도 있고, WebAssembly 컴파일러가 컴파일 시점에 일부 경계 검사를 제거할 수도 있습니다. 하지만 32비트 WebAssembly에서 가능한 “경계 검사 완전 제거”를 절대적으로 능가할 수는 없습니다.
또한 WebAssembly의 JS API는 메모리 최대 크기를 16GB로 제한합니다. 네이티브 메모리 한도에 익숙한 개발자에게는 꽤 실망스러울 수 있습니다. 불행히도 WebAssembly는 “예약(reserved)”과 “커밋(committed)” 메모리를 구분하지 않기 때문에, 브라우저는 시스템 커밋 한도에 걸리지 않으면서 자유롭게 대량의 메모리를 할당할 수 없습니다.
그럼에도 16GB에 접근할 수 있다는 것은 어떤 애플리케이션에는 매우 유용합니다. 더 많은 메모리가 필요하고, 성능 저하를 감수할 수 있다면 Memory64가 올바른 선택일 수 있습니다.
WebAssembly는 여기서 어디로 갈 수 있을까요? Memory64는 오늘날 대부분의 개발자에게는 제한적인 효용만 있을지 모르지만, 미래에는 흥미로운 가능성들이 있습니다:
앞으로 하드웨어에서 경계 검사를 더 잘 지원할 수 있습니다. 이미 이 방향의 연구가 일부 진행되었습니다. 예를 들어 Narayan 등(Narayan et. al.)의 2023년 논문을 참고하세요. WebAssembly와 다른 샌드박스 VM이 점점 더 대중화되면서, 이는 성능을 개선하면서도 큰 예약으로 낭비되는 주소 공간을 제거하는 매우 큰 변화가 될 수 있습니다. (모든 WebAssembly 호스트가 브라우저처럼 주소 공간을 자유롭게 쓸 수 있는 것은 아닙니다.)
제가 공동 챔피언으로 참여하고 있는 WebAssembly의 memory control 제안은 WebAssembly 메모리를 위한 새로운 기능들을 탐색하고 있습니다. 현재의 어떤 아이디어도 경계 검사 필요성을 없애지는 못하지만, 가상 메모리 하드웨어를 활용해 더 큰 메모리를 가능하게 하거나, 큰 주소 공간을 더 효율적으로 사용(예: 메모리 할당기의 단편화 감소)하거나, 대체 메모리 할당 기법을 제공할 수 있습니다.
Memory64는 오늘날 대부분의 개발자에게는 중요하지 않을 수 있지만, 우리는 이것이 WebAssembly의 메모리에 있어 흥미로운 미래로 가는 중요한 디딤돌이라고 생각합니다.
제안의 나머지 부분은 i64 모드를 더 구체화합니다. 예를 들어 memory.fill 같은 명령을 수정해, 메모리의 주소 타입에 따라 i32 또는 i64를 받도록 합니다. 또한 제안은 테이블(tables) 에도 i64 모드를 추가합니다. 테이블은 함수 포인터와 간접 호출의 주요 메커니즘입니다. 단순화를 위해 이 글에서는 생략합니다.↩
실제로는 명령이 더 복잡할 수 있는데, 정수 오버플로, offset, align도 고려해야 하기 때문입니다.↩
SpiderMonkey JS 셸을 사용한다면, 내보낸(exported) 어떤 WebAssembly 함수에든 wasmDis(func)를 적용해 직접 확인해 볼 수 있습니다.↩
일부 하드웨어는 이제 48비트보다 큰 주소도 지원합니다. 예를 들어 57비트 주소와 5단계 페이징(5-level paging)을 지원하는 Intel 프로세서가 있지만, 아직 보편적이지는 않습니다.↩
실제로는 offset과 align을 고려하기 위해 4GB를 약간 넘는 추가 페이지들을 “가드 페이지(guard pages)”로 예약합니다. 모든 가능한 포인터에 대해 모든 가능한 오프셋을 처리하려면 추가로 4GB(총 8GB)를 예약할 수도 있겠지만, SpiderMonkey에서는 대신 가드 페이지로 32MiB + 64KiB만 예약하고, 이보다 큰 오프셋에 대해서는 명시적 경계 검사로 폴백합니다. (실제로 큰 오프셋은 매우 드뭅니다.) 각 지원 플랫폼에서 경계 검사를 어떻게 처리하는지에 대한 자세한 내용은 이 SMDOC 주석(약간 오래된 듯합니다), 이 상수들, 이 Ion 코드를 참고하세요. 또한 이 할당 스킴을 쓸 수 없을 때(예: 32비트 기기나 자원이 제한된 모바일 폰)에는 명시적 경계 검사로 폴백한다는 점도 언급할 가치가 있습니다.↩