关于 WebAssembly 作为堆栈机器的思考Thoughts on WebAssembly as a stack machine
Eli Bendersky 回应了一篇广为流传的文章,该文指出 WebAssembly 并非纯粹的堆栈机,因其拥有局部变量且缺少如 dup 和 swap 等堆栈操作指令。作者认为尽管 WASM 基于堆栈模型,但其引入的局部变量机制使其更接近寄存器式虚拟机,这影响了其执行效率和编程范式。
本周,文章《Wasm 并不完全是一台栈机器》在圈内流传开来,引起了我的注意。该文声称 WASM 并非纯粹的栈机器,因为它拥有局部变量(locals),并且缺少一些栈操作指令,比如 dup 和 swap。
虽然我未必完全反对这一观点,但就我个人看法而言,这更像是一个语义层面的讨论,因为据我所知,目前并没有对“栈机器”的正式定义。例如维基百科就指出:
[...],栈机器是一种计算机处理器或过程虚拟机,其主要交互方式是通过压入弹出栈来移动短生命周期的临时值。
WASM 显然符合这一定义;其核心交互正是通过栈进行的,尽管 WASM 还增加了一个无限的寄存器文件(即 locals)。而更纯粹意义上的栈机器,如 Forth,则仅依赖栈和内存(内存指针也通过栈管理);WASM 同样具备这两者,另外还有寄存器支持。
说到 Forth,文中提到 dup 让我想起了自己用这门语言编程时的印象——我在关于用 Go 和 C 实现 Forth 的文章中有过记录。在那篇文章中,我强调了以下这个 Forth 中的关键库函数:它用于将一个加数加到内存中存储的值上。
: +! ( addend addr -- )
tuck ( addr addend addr )
@ ( addr addend value-at-addr )
+ ( addr updated-value )
swap ( updated-value 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 更好?但从可读性角度我们已经讨论过了,那性能呢?由于栈式虚拟机只是一个抽象层,而底层执行此代码的 CPU 本质上是寄存器架构的机器,所以答案是否定的——根本无关紧要。
现代编译器工程师都是在 C 及其后代语言的熔炉中锤炼出来的;任意控制流、任意寄存器和内存访问,一切皆有可能。编译器已经相当复杂了。让我们看看 wasmtime 如何将我们的 add_to_byte 编译为本地代码(使用 wasmtime explore,默认 opt-level=2);以下是我添加的注释:
// Prologue
push rbp
mov rbp, rsp
// wasmtime's VM context pointer lives in rdi; 0x38 is likely its offset
// to the default linear memory. Therefore, r10 will hold the base address
// of the linear memory buffer
mov r10, qword ptr [rdi + 0x38]
// The first parameter ($addr) is in edx; since WASM values are i32, it's
// zero-extended into the 64-bit r11 by copying into r11d
mov r11d, edx
// r10+r11 is memory[$addr]; this loads the current value into rsi
// (zero-extending from 8 bits)
movzx rsi, byte ptr [r10 + r11]
// ecx is the first parameter ($delta); this adds the addend to the
// current value
add esi, ecx
// Store cur_value+addend back into memory[$addr]
mov byte ptr [r10 + r11], sil
// Epilogue
mov rsp, rbp
pop rbp
ret这基本上就是我们预期由 C 语句 mem[addr] += addend 生成的代码,或者如果我们手动编写 x86-64 汇编也会得到类似结果。编译器很容易就能判断出连续两次从同一个 WASM 局部变量加载会得到相同值,实际上不必重复加载。WASM 模型使得这一点变得容易,因为你无法让局部变量别名化;只要没有对该局部变量进行中间写入,多次读取已知会产生相同结果(冗余加载消除)。
如有意见,请邮件联系我。
需要完整排版与评论请前往来源站点阅读。