`--spill-pointers` does not spill a value held on the Wasm value stack across a call
Les mainteneurs répondent en général sous 1 jour
Personne n'a encore pris cette issue.
Évaluation
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Accessibilité débutants
- 45/100
Piste de recherche
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.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
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.
- Langage dominant
- WebAssembly
- Étoiles
- 8.7k
- Forks
- 900
- Merge moyen
- 2 j 5 h
- PR mergées (30 j)
- 106
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Aucun modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de WebAssembly/binaryen
-
Difficulté 1/5 Moins d'une heure Accessibilité débutants 88/100
WebAssembly/binaryen#9135 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 Une demi-journée Accessibilité débutants 76/100
WebAssembly/binaryen#9018 · 3 commentaires ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 3/5 1-2 jours Accessibilité débutants 76/100
WebAssembly/binaryen#9291 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 3/5 1-2 jours Accessibilité débutants 62/100
WebAssembly/binaryen#9257 · 2 commentaires ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 3/5 1-2 jours Accessibilité débutants 45/100
WebAssembly/binaryen#9249 ·
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de WebAssembly/binaryen
Issues similaires
-
b:minor bug
Difficulté 2/5 1-3 heures Accessibilité débutants 76/100
software-mansion/argent#1698 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 1/5 Moins d'une heure Accessibilité débutants 76/100
huggingface/huggingface_hub#5283 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 78/100
Les mainteneurs répondent en général sous 1 jour
-
enhancement
Difficulté 2/5 1-3 heures Accessibilité débutants 66/100
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 84/100
HKUDS/Vibe-Trading#2115 ·
Les mainteneurs répondent en général sous 1 jour