`--spill-pointers` does not spill a value held on the Wasm value stack across a call
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 45/100
Direzione di ricerca
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.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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:
- C and
wasm-optrun
(this reproducer, 7 jobs) - ECL run
(the companionspill-pointers-eclreproducer, 2 jobs)
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-pointersfails 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. --flattenraises local counts. On a large module (Maxima compiled by ECL)
we also needed--simplify-locals-notee-nostructure --vacuum --coalesce-locals --reorder-localsafter spilling to stay below V8's limit of
50,000 locals per function. That is not shown by these fixtures.
- Lingua principale
- WebAssembly
- Stelle
- 8.7k
- Fork
- 900
- Merge medio
- 2g 5h
- PR unite (30g)
- 106
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di WebAssembly/binaryen
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
WebAssembly/binaryen#9135 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 Mezza giornata Idoneità per principianti 76/100
WebAssembly/binaryen#9018 · 3 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 76/100
WebAssembly/binaryen#9291 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 62/100
WebAssembly/binaryen#9257 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 45/100
WebAssembly/binaryen#9249 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di WebAssembly/binaryen
Issue simili
-
bug ready-for-agent
Difficoltà 2/5 1-3 ore Idoneità per principianti 73/100
PhyberApex/kuroshiro#1687 ·
I maintainer di solito rispondono entro 1 giorno
-
Provider test fails on official OpenAI preset: chat probe sends max_tokens, rejected by newer modelsAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno
-
general_error() logs every 4xx response at ERROR level, including expected 400, 404 and 409 outcomesApertabug
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
awslabs/visual-asset-management-system#935 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
I maintainer di solito rispondono entro 1 giorno
-
vim.filetype.match() prefixes a URL buffer name with the cwd before passing it to filetype functionsAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 73/100
I maintainer di solito rispondono entro 1 giorno