Follow-on to #1728 (the __wasi__ setjmp fix), and separable from it: that fix makes --target=wasm32-wasi --emit=lib usable; this is what stands between it and being preferable to a hand-rolled zig cc script. Raised by the html-sanitizer downstream; measurements below are mine, taken on main after #1728.
The observation
A two-function library cross-compiled to wasm is 956 KB:
risky(x: int) -> int { if x < 0 { panic("negative") } return x * 2 }
safe(x: int) -> int { try { return risky(x) } catch reason { return 0 } }
$ ae build --target=wasm32-wasi --emit=lib paniclib.ae -o w.wasm
$ ls -l w.wasm
956374
Section breakdown — 97.4% of the module is debug information:
| section |
bytes |
share |
.debug_loc |
302,051 |
31.6% |
.debug_info |
285,752 |
29.9% |
.debug_line |
209,071 |
21.9% |
.debug_str |
60,030 |
6.3% |
.debug_ranges |
30,358 |
3.2% |
.debug_pubnames |
23,900 |
2.5% |
.debug_abbrev |
11,475 |
1.2% |
all .debug* + name |
931,164 |
97.4% |
Code and data are the remaining 2.6%. Removing the debug and name sections by hand gives 25,177 bytes — a 38× reduction — and the stripped module is behaviourally identical: it still validates, still exports all six symbols, and still runs correctly under node's WASI, including the fail-stop panic path from #1728.
(The downstream reported 2.0 MB → 324 KB, 82% DWARF, on a much larger module. Same phenomenon; the share is higher here because there is less real code to dilute it.)
Two findings that shape the fix
1. This is specific to the cross path. Native --emit=lib on the same source is 69 KB with zero .debug* sections — strip --strip-all recovers only 14%. So the report's suggestion that debug info in a shipped --emit=lib artifact "may be unintended for all targets" does not hold: native is already clean. The problem is confined to the cross backend.
2. It is not a stray -g in an obvious place. tools/ae_cross.c uses optimize ? "-O2" : "-O0 -g" at both compile sites, and a trivial zig cc -target wasm32-wasi -O2 -c produces no debug sections at all. So the DWARF is entering through how the runtime + stdlib translation units are compiled and archived for the target, not through a literal -g on the user program. Worth someone tracing rather than guessing — I did not want to file a fix for a cause I had not pinned down.
Also of note: --gc-sections is already applied on the wasm link path (ae_cross.c:510), so dead-code elimination is not the missing piece. The downstream's export-count comparison (their script 20 symbols, ours 46) suggests it is not pruning as much as it could, but that is a smaller effect than the DWARF.
What I would like
Either shape works, and I have a mild preference:
Preferred — a size-oriented build mode. ae build already has --quick (-O0 -g) and --profile (-O2 -g, #1718). A third point on that axis is the consistent move: something like --release or --size implying -Oz plus -Wl,--strip-all. It keeps the "named modes for named intents" design rather than pushing users to assemble flag strings, and it is discoverable in ae help where a passthrough is not.
Alternative — --cflags= / --ldflags= passthrough. More flexible, and it unblocks cases nobody anticipated. But it makes the toolchain's flag surface part of our public contract by accident, and "what does ae pass by default" becomes something users have to reverse-engineer to know what they are overriding.
My opinion, since it was invited: I would do the mode, and I would not make it the default. A 38× size difference is dramatic enough that someone shipping to a browser will find the flag; whereas silently stripping debug info from every cross build would make the first "why can't I get a stack trace from my wasm module" report genuinely hard to diagnose. Named modes make the trade-off visible at the point of choosing it.
One caveat on -Oz specifically: it is a real code-generation change, not just a stripping step, so it wants its own verification pass rather than being bundled in as an obvious win. The stripping half is where essentially all the size is here (97.4%), and it is provably behaviour-preserving. If the two were split, the stripping half could land quickly and independently.
Reproducing
ae build --target=wasm32-wasi --emit=lib <lib>.ae -o out.wasm
# section sizes: any wasm section dumper, or a dozen lines of node reading the
# section headers directly -- that is how the table above was produced.
🤖 Generated with Claude Code
Follow-on to #1728 (the
__wasi__setjmp fix), and separable from it: that fix makes--target=wasm32-wasi --emit=libusable; this is what stands between it and being preferable to a hand-rolledzig ccscript. Raised by the html-sanitizer downstream; measurements below are mine, taken onmainafter #1728.The observation
A two-function library cross-compiled to wasm is 956 KB:
Section breakdown — 97.4% of the module is debug information:
.debug_loc.debug_info.debug_line.debug_str.debug_ranges.debug_pubnames.debug_abbrev.debug*+nameCode and data are the remaining 2.6%. Removing the debug and
namesections by hand gives 25,177 bytes — a 38× reduction — and the stripped module is behaviourally identical: it still validates, still exports all six symbols, and still runs correctly under node's WASI, including the fail-stop panic path from #1728.(The downstream reported 2.0 MB → 324 KB, 82% DWARF, on a much larger module. Same phenomenon; the share is higher here because there is less real code to dilute it.)
Two findings that shape the fix
1. This is specific to the cross path. Native
--emit=libon the same source is 69 KB with zero.debug*sections —strip --strip-allrecovers only 14%. So the report's suggestion that debug info in a shipped--emit=libartifact "may be unintended for all targets" does not hold: native is already clean. The problem is confined to the cross backend.2. It is not a stray
-gin an obvious place.tools/ae_cross.cusesoptimize ? "-O2" : "-O0 -g"at both compile sites, and a trivialzig cc -target wasm32-wasi -O2 -cproduces no debug sections at all. So the DWARF is entering through how the runtime + stdlib translation units are compiled and archived for the target, not through a literal-gon the user program. Worth someone tracing rather than guessing — I did not want to file a fix for a cause I had not pinned down.Also of note:
--gc-sectionsis already applied on the wasm link path (ae_cross.c:510), so dead-code elimination is not the missing piece. The downstream's export-count comparison (their script 20 symbols, ours 46) suggests it is not pruning as much as it could, but that is a smaller effect than the DWARF.What I would like
Either shape works, and I have a mild preference:
Preferred — a size-oriented build mode.
ae buildalready has--quick(-O0 -g) and--profile(-O2 -g, #1718). A third point on that axis is the consistent move: something like--releaseor--sizeimplying-Ozplus-Wl,--strip-all. It keeps the "named modes for named intents" design rather than pushing users to assemble flag strings, and it is discoverable inae helpwhere a passthrough is not.Alternative —
--cflags=/--ldflags=passthrough. More flexible, and it unblocks cases nobody anticipated. But it makes the toolchain's flag surface part of our public contract by accident, and "what doesaepass by default" becomes something users have to reverse-engineer to know what they are overriding.My opinion, since it was invited: I would do the mode, and I would not make it the default. A 38× size difference is dramatic enough that someone shipping to a browser will find the flag; whereas silently stripping debug info from every cross build would make the first "why can't I get a stack trace from my wasm module" report genuinely hard to diagnose. Named modes make the trade-off visible at the point of choosing it.
One caveat on
-Ozspecifically: it is a real code-generation change, not just a stripping step, so it wants its own verification pass rather than being bundled in as an obvious win. The stripping half is where essentially all the size is here (97.4%), and it is provably behaviour-preserving. If the two were split, the stripping half could land quickly and independently.Reproducing
🤖 Generated with Claude Code