Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

`--spill-pointers` does not spill a value held on the Wasm value stack across a call

未关闭
#9,307 2 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
45/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
cpp, github-actions, wasm
领域
backend, compilers

调研方向

Start with SpillPointers.cpp and the --spill-pointers pass logic described in the issue. Run wasm-opt/check.py on wasm-opt/operand.wat using the workflow in .github/workflows/spill-pointers.yml; the reproducer expects spill stores for $operand and $local-operand as well as $control. Done means the checks pass and the end-to-end reproducer no longer frees a live object during collection.

由索引模型根据 Issue 内容生成。

描述

Boehm GC on Emscripten cannot scan Wasm locals or the Wasm value stack. Code
that relies on conservative collection (for example ECL, and Maxima built on
it) links with -sBINARYEN_EXTRA_PASSES=--spill-pointers, so that pointers
live across a call are stored to the shadow stack that the collector scans.

The pass does not spill a value that waits on the value stack as an earlier
operand of a call while a later operand calls out. If that later call
collects, the object can be freed while it is still in use. Running
--flatten immediately before --spill-pointers makes the pass spill it.

We first hit this in Maxima running on ECL compiled to Wasm: with Maxima
built at -Oz, an intermittent Lisp error appeared
while running the STACK-assessment Maxima package. The same workload
built at -O2 did not show it. It turned out the collector had freed a live object
that --spill-pointers had not spilled, and its memory was then reused.

The author used Claude to pin this problem down, and Claude suggested the reproducers
below and pinned the issue down to SpillPointers.cpp as described in this issue.
The author checked the reproducer functions and observed real memory errors both
in his app and with provided reproducers, however this issue lays way outside author's
comfort area when it comes to software development.
Please take this in consideration when reviewing.

Everything below comes from two GitHub Actions runs on commit 6d08b8f:

Minimal wasm-opt testcase

wasm-opt/operand.wat; $collect stands for any call that can collect:

(func $control              ;; $x is read after collect()
  (local $x i32)
  (local.set $x (call $make))
  (drop (call $collect))
  (call $use1 (local.get $x)))
(func $operand              ;; make()'s result waits on the value stack
  (call $use (call $make) (call $collect)))
(func $local-operand        ;; local.get $x is evaluated before collect()
  (local $x i32)
  (local.set $x (call $make))
  (call $use (local.get $x) (call $collect)))
wasm-opt operand.wat --spill-pointers -S -o plain.wat
wasm-opt operand.wat --flatten --spill-pointers -S -o flatten.wat

Spill stores (i32.store to the shadow stack) per function:

Function --spill-pointers --flatten --spill-pointers
$control 1 1
$operand 0 1
$local-operand 0 1
Binaryen Job
version_123-168-g8c82b6884 (emsdk 4.0.10) log
version_133-96-ga631c1445 (emsdk 6.0.13) log
version_133-119-gf1f3a483 (main at f1f3a483, built from source) log

Each log prints both output modules in full. In plain.wat, $operand and
$local-operand are unchanged. $local-operand shows that the value need not
come straight from a call: once $x has been read as an operand it is dead for
liveness purposes, although its value is still in use until $use runs.

End-to-end: C and bdwgc

spill-gap.c evaluates intact(make(id), churn(id), id) through function
pointers, so the calls are not inlined. make() allocates an object with
GC_MALLOC; churn() runs GC_gcollect() while make()'s result waits as
the first operand. Each case runs 2,000 rounds with one full collection each.

  • freed: a disappearing link on the object was cleared, so the collector
    reclaimed an object that was still live.
  • spilled: rounds in which the shadow stack inside main's frame held the
    object's address at the collection.
  • stale: rounds in which a copy of the address lay in dead stack words below
    main's frame. The fixture pads and scrubs the stack so that no stale word
    keeps the object alive by chance; this was 0 in every case.

freed / spilled, the same for bdwgc 8.2.8 (bundled in the ECL Emscripten fork
at e4bdb2e5) and bdwgc 8.2.12 (4fab5386):

Optimization emsdk No spill pass --spill-pointers --flatten,--spill-pointers
-O0 4.0.10 2,000 / 0 0 / 2,000 0 / 2,000
-O0 6.0.13 2,000 / 0 2,000 / 0 0 / 2,000
-O2 both 2,000 / 0 2,000 / 0 0 / 2,000
-Oz both 2,000 / 0 2,000 / 0 0 / 2,000

At -O2 and -Oz, --spill-pointers changes nothing for this pattern: the
object is freed in every round, as without the pass. At -O0 the outcome
depends on clang: with emsdk 4.0.10 the pass does save the object (it is freed
without the pass and spilled with it), with emsdk 6.0.13 it does not.

emsdk bdwgc Job
4.0.10 (emcc 4.0.10, Binaryen version_123-168, Node 22.16.0) 8.2.8 log
4.0.10 8.2.12 log
6.0.13 (emcc 6.0.13, Binaryen version_133-96, Node 24.19.0) 8.2.8 log
6.0.13 8.2.12 log

Each job uploads report.json (every build and run command, exit code and
output) and O2-spill-names.wat (the -O2 plain-spill module as text).

Further observations from the same jobs:

  • With emsdk 6.0.13, --spill-pointers fails on a module without a name
    section: Fatal: getStackSpace: failed to find the stack pointer. The
    fixture therefore links with --profiling-funcs, as ECL's own wasm32 link
    flags do. With emsdk 4.0.10 the stripped builds give the same results as the
    named ones.
  • The report also counts objects whose contents were overwritten (lost).
    This undercounts frees because a freed block is not always reused in the same
    round: 1 of 2,000 at -O2/-Oz, 2,000 of 2,000 at -O0.

The companion ECL reproducer shows the same result through ECL's public API,
with ECL built from source as its INSTALL describes
(ECL run).

Mechanism

SpillPointers.cpp computes liveness over the function's existing locals and
spills the pointer-typed locals live across each call. A call result used
directly as an operand of a later call is not a local, so it is never
considered. A local that has already been read as an operand is no longer live
at the later call. When the pass rewrites a call, it moves that call's own
operands into new temporaries, which are not spilled either. The pass logic is
unchanged between version_123 and main at f1f3a483. --flatten turns
every such value into a local before liveness is computed.

Hardware and system

All jobs ran on GitHub-hosted ubuntu-24.04 runners (image
20261012.334.1). Each job prints its runner in the step "Record the runner
hardware and system". For example, the Binaryen main
job

reports x86-64, 4 CPUs, AMD EPYC 7763, 15 GiB RAM, Ubuntu 24.04.5, Linux
6.17.0-1022-azure. The CPU model differs between jobs: the emsdk 4.0.10 /
bdwgc 8.2.8 job

reports AMD EPYC 9V74. The counts were identical across jobs.

Running it

The workflow .github/workflows/spill-pointers.yml runs everything above. To
run it locally (Linux, Python 3, Git; the SDK supplies clang, Binaryen and
Node), from this directory:

git clone https://github.com/emscripten-core/emsdk.git
emsdk/emsdk install 6.0.13 && emsdk/emsdk activate 6.0.13
git clone https://github.com/ivmai/bdwgc.git && git -C bdwgc checkout v8.2.12
python3 run.py --emsdk "$PWD/emsdk" --gc "$PWD/bdwgc" --output "$PWD/out"
python3 wasm-opt/check.py --wasm-opt emsdk/upstream/bin/wasm-opt --output "$PWD/out-wasm-opt"

run.py fails unless, at every optimization level, the flattened build frees
nothing and spills in every round, and, at -O2 and -Oz, plain spilling
frees in every round with no spill. check.py fails unless the store counts
match the table above.

Limits

  • The fixtures force the pattern with indirect calls and a collection in every
    round. They show that the pass misses such values, not how often real code
    produces them. In real programs a stale stack word often keeps the object
    alive, so the failures are intermittent.
  • --flatten raises local counts. On a large module (Maxima compiled by ECL)
    we also needed --simplify-locals-notee-nostructure --vacuum --coalesce-locals --reorder-locals after spilling to stay below V8's limit of
    50,000 locals per function. That is not shown by these fixtures.
主要语言
WebAssembly
星标
8.7k
派生
900
平均合并
2 天 5 小时
30 天内合并 PR
106

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 阅读贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

WebAssembly/binaryen 的其他 Issue

查看 WebAssembly/binaryen 的全部 Issue

相似的 Issue

更多 Backend & API Design Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。