WebAssembly가 순수한 스택 머신인지, 그리고 로컬과 스택 조작 없이도 왜 여전히 읽기 쉽고 효율적인지에 대한 고찰.
Toggle navigation
Eli Bendersky's website
2026년 4월 29일 19:28 태그WebAssembly , 컴파일
이번 주에는 Wasm is not quite a stack machine라는 글이 여기저기서 회자되었고, 제 눈길도 끌었습니다. 이 글은 WASM이 순수한 스택 머신이 아니라고 주장하는데, 로컬이 있고 dup 및 swap 같은 몇몇 스택 조작 연산이 빠져 있기 때문이라는 것입니다.
제가 꼭 동의하지 않는 것은 아니지만, 제 생각에는 이것은 다소 의미론적인 논의입니다. 제가 알기로는 무엇이 스택 머신인지에 대한 형식적인 정의가 없기 때문입니다. 예를 들어 Wikipedia는 이렇게 말합니다:
[...], 스택 머신은 주된 상호작용이 짧은 수명의 임시 값을 푸시다운 스택으로 보내고 다시 가져오는 데 있는 컴퓨터 프로세서 또는 프로세스 가상 머신이다.
WASM은 분명히 이 정의에 들어맞습니다. 주된 상호작용은 스택을 통해 이루어지지만, WASM은 무한한 레지스터 파일(로컬)로 보강되어 있습니다. Forth 같은 더 순수한 스택 머신은 스택과 메모리만으로 제한되며(그 안을 가리키는 포인터는 스택에서 관리됩니다), WASM도 이것들을 갖고 있고 여기에 레지스터가 추가로 있습니다.
Forth 이야기가 나왔으니, dup에 대한 언급은 Go와 C로 Forth 구현하기에 관한 제 글에서 기록했던 그 언어로 프로그래밍한 제 인상을 떠올리게 했습니다. 거기서 저는 Forth의 다음 핵심 라이브러리 함수를 강조했습니다. 이 함수는 메모리에 저장된 값에 가산 값을 더합니다.
: +! ( addend addr -- ) tuck ( addr addend addr ) @ ( addr addend value-at-addr )
그리고 저는 이런 코드가 옆의 주석에 있는 자세한 스택 뷰 없이는 이해하기가 얼마나 어려운지 한탄했습니다.
저는 이 WASM 코드를 훨씬 더 쉽게 이해할 수 있다고 느낍니다:
(func (export "add_to_byte") (param $addr i32) (param $delta i32) (i32.store8 (local.get $addr) (i32.add (i32.load8_u (local.get $addr)) (local.get $delta))) )
접힌 WASM 명령이 가독성을 높여 주고 단지 문법적 설탕일 뿐이니, 이것은 반칙이라고 말할 수도 있습니다. 좋습니다, 그렇다면 선형 코드는 다음과 같습니다:
local.get $addr local.get $addr i32.load8_u local.get $delta i32.add i32.store8
이것도 여전히 매우 읽기 쉽습니다. 모든 계산과 실제 명령에는 스택이 사용되지만, 일부 데이터는 스택 위가 아니라 이름 붙은 "레지스터"에 들어 있기 때문입니다. 그래서 올바른 순서로 값을 맞추기 위해 그런 tuck-swap 곡예를 할 필요가 없습니다.
local.get $addr가 중복된다는 점이 걱정될 수도 있습니다. 진짜 dup가 더 낫지 않을까요? 음, 가독성 측면에서는 그렇지 않습니다. 이 점은 이미 이야기했습니다. 성능은 어떨까요? 스택 VM은 그저 추상화일 뿐이고, 이 코드를 실제로 실행하는 하부 CPU는 어차피 레지스터 머신이므로, 답은 아니오입니다. 전혀 중요하지 않습니다.
현대의 컴파일러 엔지니어들은 C와 그 후손들의 불길 속에서 단련되었습니다. 임의의 제어 흐름, 임의의 레지스터 및 메모리 접근, 무엇이든 가능합니다. 컴파일러는 상당히 정교합니다. wasmtime이 우리의 add_to_byte를 네이티브 코드로 어떻게 컴파일하는지 봅시다(wasmtime explore를 기본 opt-level=2로 사용). 주석은 제가 추가했습니다:
// 프롤로그 push rbp mov rbp, rsp
// wasmtime의 VM 컨텍스트 포인터는 rdi에 있으며, 0x38은 아마도 // 기본 선형 메모리에 대한 오프셋일 것입니다. 따라서 r10이 // 선형 메모리 버퍼의 베이스 주소를 담게 됩니다 mov r10, qword ptr [rdi + 0x38]
// 첫 번째 매개변수($addr)는 edx에 있습니다. WASM 값은 i32이므로, // r11d로 복사해서 64비트 r11로 0-확장됩니다 mov r11d, edx
// r10+r11은 memory[$addr]입니다. 이것이 현재 값을 rsi로 로드합니다 // (8비트에서 0-확장) movzx rsi, byte ptr [r10 + r11]
// ecx는 첫 번째 매개변수($delta)입니다. 이것이 가산 값을 현재 값에 // 더합니다 add esi, ecx
// cur_value+addend를 memory[$addr]에 다시 저장합니다 mov byte ptr [r10 + r11], sil
// 에필로그 mov rsp, rbp pop rbp ret
이것은 우리가 C 문장 mem[addr] += addend에 대해 생성되리라 기대하는 코드와 거의 같고, 혹은 x86-64 어셈블리를 손으로 쓴다고 해도 비슷합니다. 컴파일러는 같은 WASM 로컬에서 연속으로 두 번 로드하는 것이 같은 값을 만들어 내며 실제로는 중복될 필요가 없다는 점을 아무 어려움 없이 알아냈습니다. WASM 모델은 이를 꽤 쉽게 만들어 줍니다. 로컬에는 별칭을 만들 수 없기 때문입니다. 같은 로컬에 대한 중간 쓰기가 없는 한, 여러 번의 읽기는 같은 값을 산출한다고 알 수 있습니다(중복 로드 제거).
댓글은 이메일로 보내 주세요.
© 2003-2026 Eli Bendersky