Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/llgo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,7 @@ jobs:
run: |
python3 -m unittest discover -s dev -p test_wasm_debug_info_test.py
python3 dev/test_wasm_debug_info.py --profile j32 --profile j64 --profile w32 --artifact embedded --artifact external
python3 dev/test_wasm_debug_info.py --wasm-opt=size --opt 0 --artifact embedded --artifact external

- name: Test optimized Emscripten runtime boundaries
if: matrix.suite == 'runtime'
Expand All @@ -1005,6 +1006,7 @@ jobs:
run: |
node --test test/wasm/optimization/asyncify-exports.test.mjs
python3 test/wasm/optimization/test.py --browser
python3 test/wasm/optimization/test.py --browser --wasm-opt=size

- name: Build standard runtime for wasm
if: matrix.suite == 'runtime'
Expand Down
2 changes: 2 additions & 0 deletions benchmark/wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ go run ./benchmark/wasm -root "$PWD" -llgo /path/to/llgo \
The `println` profile builds have one warm-up followed by measured builds; the CLI defaults to three measurements and reports their median. `cprintf` and `fmtprintf` are size-only cases and build once per profile. Both the named W32/WASI target and raw `GOOS=wasip1 GOARCH=wasm` entry use the typed-bridge fallback, and the harness records both module sizes. To expose its compile cost without duplicating an equivalent timed build, `reflectcall` times only the named W32/WASI entry; JavaScript profiles remain size-only. With `LLGO_WASM_BENCH_BUILD_RUNS=1`, CI performs 26 LLGo builds and six official-Go reference builds per revision. A single CI timing sample is noisier than the default median, but base and head run sequentially with the same fixtures and settings on one runner.

The existing WebAssembly benchmark job tests this harness and measures both PR base and head, publishing their results through `.github/llgo-wasm-benchmark.yml`.

For explicit final optimization, metadata stripping and complete deployment accounting, see [Final WebAssembly size optimization](../../doc/wasm-size.md). The existing benchmark defaults remain unchanged.
2 changes: 1 addition & 1 deletion cmd/internal/compat/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const TestBinaryFlags = "artifacts bench benchmem benchtime blockprofile blockpr
var Extensions = map[string]string{
"n": "cmd/internal/bug", "v": "internal/monitor", "C": "cmd/internal/flags", "O": "cmd/internal/flags", "O0": "cmd/internal/flags", "O1": "cmd/internal/flags", "O2": "cmd/internal/flags", "O3": "cmd/internal/flags", "Os": "cmd/internal/flags", "Oz": "cmd/internal/flags",
"compiler-verbose": "cmd/internal/flags", "cv": "cmd/internal/flags", "lto": "cmd/internal/flags", "lto-pass-plugin": "cmd/internal/flags", "globaldce": "cmd/internal/flags",
"pclntab": "cmd/internal/flags", "debug-artifact": "cmd/internal/flags", "deadcodedrop": "cmd/internal/flags", "goroutine-stack-size": "cmd/internal/flags", "pthread-stack-size": "cmd/internal/flags",
"wasm-opt": "cmd/internal/flags", "wasm-strip": "cmd/internal/flags", "pclntab": "cmd/internal/flags", "debug-artifact": "cmd/internal/flags", "deadcodedrop": "cmd/internal/flags", "goroutine-stack-size": "cmd/internal/flags", "pthread-stack-size": "cmd/internal/flags",
"check-linkargs": "internal/build", "check-llfiles": "internal/build", "gen-llfiles": "internal/build", "force-espclang": "internal/build",
"debug-trace": "internal/build", "target": "internal/crosscompile", "emulator": "internal/build", "port": "internal/flash", "baudrate": "internal/monitor",
"size": "internal/build", "size-format": "internal/build", "size-level": "internal/build", "obin": "internal/build", "ohex": "internal/build", "oimg": "internal/build", "ouf2": "internal/build", "ozip": "internal/build",
Expand Down
7 changes: 7 additions & 0 deletions cmd/internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ func AddBuildFlags(fs *flag.FlagSet) {
fs.StringVar(&LTOPluginPath, "lto-pass-plugin", "", "Load an LLVM LTO pass plugin during full LTO (ELF lld or Mach-O ld64.lld)")
AddGlobalDCEFlag(fs)
addPCLNFlag(fs)
addWasmFinalFlags(fs)
addDebugArtifactFlag(fs)
fs.StringVar(&Tags, "tags", "", "Build tags")
fs.Var(&PthreadStackSize, "goroutine-stack-size", "Stack size for native threads and WebAssembly goroutines, e.g. 2MB (0 uses the platform default)")
Expand Down Expand Up @@ -425,6 +426,12 @@ func UpdateConfig(conf *build.Config) error {
if LTO.Specified {
conf.LTO = LTO.Mode
}
if WasmOpt != "" {
conf.WasmOpt = build.WasmOptMode(WasmOpt)
}
if WasmStrip != "" {
conf.WasmStrip = build.WasmStripMode(WasmStrip)
}
if PCLN.Specified {
conf.PCLNMode = PCLN.Mode
conf.PCLNModeSet = true
Expand Down
40 changes: 40 additions & 0 deletions cmd/internal/flags/wasm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package flags

import (
"flag"
"fmt"
"slices"
)

var WasmOpt, WasmStrip string

func addWasmFinalFlags(fs *flag.FlagSet) {
WasmOpt, WasmStrip = "", ""
add := func(name, usage string, dst *string, values ...string) {
fs.Func(name, usage, func(value string) error {
if !slices.Contains(values, value) {
return fmt.Errorf("invalid %s mode %q (valid: %v)", name, value, values)
}
*dst = value
return nil
})
}
add("wasm-opt", "Final WebAssembly optimization: off or size (default off)", &WasmOpt, "off", "size")
add("wasm-strip", "Final WebAssembly metadata stripping: none, debug, or all (default none)", &WasmStrip, "none", "debug", "all")
}
48 changes: 48 additions & 0 deletions cmd/internal/flags/wasm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package flags

import (
"flag"
"testing"

"github.com/xgo-dev/llgo/internal/build"
)

func TestWasmFinalFlags(t *testing.T) {
for _, args := range [][]string{{}, {"-wasm-opt=size", "-wasm-strip=debug"}, {"-wasm-opt=off", "-wasm-strip=all"}, {"-wasm-strip=none"}} {
fs := flag.NewFlagSet("wasm", flag.ContinueOnError)
AddBuildFlags(fs)
if err := fs.Parse(args); err != nil {
t.Fatal(err)
}
conf := &build.Config{}
if err := UpdateConfig(conf); err != nil {
t.Fatal(err)
}
if string(conf.WasmOpt) != WasmOpt || string(conf.WasmStrip) != WasmStrip {
t.Fatalf("config=%+v", conf)
}
}
for _, arg := range []string{"-wasm-opt=", "-wasm-opt=fast", "-wasm-strip=symbols"} {
fs := flag.NewFlagSet("bad", flag.ContinueOnError)
AddBuildFlags(fs)
if err := fs.Parse([]string{arg}); err == nil {
t.Fatalf("accepted %s", arg)
}
}
}
31 changes: 26 additions & 5 deletions dev/test_wasm_debug_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""Check final WebAssembly DWARF after linking and Binaryen rewrites."""

import argparse
import hashlib
import itertools
import json
import os
Expand Down Expand Up @@ -114,7 +115,7 @@ def check_module(module, *, dwarfdump, addr2line):
check_source_lines(module, line_table, addr2line)


def check_artifact_report(output, profile, artifact, build_log):
def check_artifact_report(output, profile, artifact, build_log, wasm_opt="off"):
module = output.with_suffix(".wasm")
role = "debug+deployment" if artifact == "embedded" else "deployment"
expected = {module: (role, "wasm")}
Expand All @@ -126,6 +127,25 @@ def check_artifact_report(output, profile, artifact, build_log):
expected[output.parent / "wasm_fs.js"] = ("deployment", "javascript")
if artifact == "external":
expected[output.with_suffix(".debug.wasm")] = ("debug", "wasm-dwarf")
if wasm_opt != "off":
manifest = output.with_suffix(".wasm-manifest.json")
expected[manifest] = ("deployment", "wasm-manifest")
data = json.loads(manifest.read_text())
recorded = {entry["path"] for entry in data["artifacts"]}
if recorded != {path.name for path in expected if path != manifest}:
raise RuntimeError(f"{manifest}: incomplete artifact set")
totals = {"payload": {"bytes": 0, "gzip_bytes": 0}, "debug": {"bytes": 0, "gzip_bytes": 0}}
for entry in data["artifacts"]:
content = (output.parent / entry["path"]).read_bytes()
if entry["bytes"] != len(content) or entry["sha256"] != hashlib.sha256(content).hexdigest():
raise RuntimeError(f"{manifest}: inaccurate artifact identity: {entry['path']}")
if entry["gzip_bytes"] <= 0:
raise RuntimeError(f"{manifest}: invalid compressed size")
total = totals["debug" if entry["role"] == "debug" else "payload"]
for key in total:
total[key] += entry[key]
if any(data[key] != total for key, total in totals.items()):
raise RuntimeError(f"{manifest}: inaccurate totals")
entries = re.findall(
r'^llgo: artifact role=(\S+) format=(\S+) size=(\d+) path=(".*")$',
build_log, re.MULTILINE,
Expand All @@ -143,6 +163,7 @@ def main():
parser.add_argument("--profile", action="append", choices=PROFILES, dest="profiles")
parser.add_argument("--opt", action="append", choices=("0", "2"), dest="opts")
parser.add_argument("--artifact", action="append", choices=("embedded", "external"), dest="artifacts")
parser.add_argument("--wasm-opt", choices=("off", "size"), default="off")
options = parser.parse_args()
profiles = options.profiles or list(PROFILES)
opts = options.opts or ["0", "2"]
Expand All @@ -161,10 +182,10 @@ def main():
output = module if profile == "w32" else stem.with_suffix(".mjs")
build_log = run(
[llgo, "build", "-target", PROFILES[profile], f"-O{opt}",
f"-debug-artifact={artifact}", "-o", str(output), str(FIXTURE)],
f"-debug-artifact={artifact}", f"-wasm-opt={options.wasm_opt}", "-o", str(output), str(FIXTURE)],
env=env,
)
check_artifact_report(output, profile, artifact, build_log)
check_artifact_report(output, profile, artifact, build_log, options.wasm_opt)
debug_module = module
if artifact == "external":
debug_module = stem.with_suffix(".debug.wasm")
Expand All @@ -187,10 +208,10 @@ def main():
output = Path(directory) / "html-report.html"
build_log = run(
[llgo, "build", "-target", PROFILES["j32"], f"-O{opts[0]}",
f"-debug-artifact={artifacts[0]}", "-o", str(output), str(FIXTURE)],
f"-debug-artifact={artifacts[0]}", f"-wasm-opt={options.wasm_opt}", "-o", str(output), str(FIXTURE)],
env=env,
)
check_artifact_report(output, "j32", artifacts[0], build_log)
check_artifact_report(output, "j32", artifacts[0], build_log, options.wasm_opt)
print("j32 HTML: complete deployment artifacts and byte sizes passed", flush=True)


Expand Down
106 changes: 106 additions & 0 deletions doc/wasm-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Final WebAssembly size optimization

Hosted W32, J32 and J64 executables support an optional final Binaryen pass:

```sh
llgo build -target=wasi -wasm-opt=size -wasm-strip=debug -o app.wasm ./app
llgo build -target=emscripten -wasm-opt=size -wasm-strip=debug -o app.mjs ./app
```

The defaults are `-wasm-opt=off` and `-wasm-strip=none`. These options do not
change the Go/LLVM optimization level, PCLN policy, GC, scheduler, reflection,
exception handling, or existing development-only optimization gates. They do
not support libraries, IR generation, or firmware output conversions.

| Option | Effect |
| --- | --- |
| `-wasm-opt=size` | Runs Binaryen `-Oz` after required linker/Asyncify transformations, with an explicit hosted-target feature allowlist. Memory64 is enabled only for J64. |
| `-wasm-strip=debug` | Removes optional DWARF, name and source-map metadata. |
| `-wasm-strip=all` | Also removes producer metadata. |

Strip-only builds validate the module but preserve its original code/data
section bytes. Stripping retains runtime PCLN, target features, build identity,
external DWARF references, and unknown custom sections. It is independent of
`-pclntab=none`, which changes runtime symbolization behavior.

For Emscripten, size mode sets `EXPORT_ALL=0`. Explicit native/public exports
and the existing runtime methods (`cwrap`, `allocateUTF8`, `stringToUTF8`,
`UTF8ToString`, `FS`, `setValue`, `getValue`, `ENV`) remain available; incidental
JS implementation symbols are no longer published on `Module`. `EXPORT_ALL`
controls publication, not reachability; the final pass does not remove these
explicit runtime APIs.

## Toolchain and debugging

Use LLGo Binaryen `llgo-v132.3`, selected through `WASMOPT` or
`EM_BINARYEN_ROOT`. CI pins its release archive checksum. The final pipeline
checks the Binaryen 132 version string and records the executable SHA-256;
the version string alone cannot distinguish upstream Binaryen from LLGo's
patched build.

Debug packaging happens after optimization:

```sh
llgo build -target=emscripten -wasm-opt=size \
-debug-artifact=external -wasm-strip=debug -o app.mjs ./app
```

Embedded DWARF is incompatible with explicit stripping. External DWARF keeps a
matching `app.debug.wasm`; stripping applies only to the deployed module.
When retaining DWARF, the final pass skips Binaryen 132's `precompute`,
`precompute-propagate` and `local-cse` passes, which lose surviving Go call-site
line mappings. Other `-Oz` passes retain Binaryen's DWARF compatibility checks.
CI checks Go/C++ source locations and executes the final modules for W32/J32/J64.

## Artifact manifest

An enabled final pipeline writes `app.wasm-manifest.json` beside the output.
It records compiler/source-Go/LLVM/link-driver identity, target profile and
pointer width, PCLN and final optimization modes, Binaryen identity/arguments,
link flags, declared and allowed Wasm features, and each artifact's byte count,
gzip-9 byte count and SHA-256. Its build ID covers sorted artifact names and
hashes. Tool names and absolute flag paths are normalized to omit local machine
directories; the manifest is a build record, not a portable linker command.
Debugger build IDs remain paired with the external DWARF container.

`payload` includes Wasm, JS/HTML loaders, worker assets and runtime symbols;
`debug` counts external debugger containers separately. Gzip totals sum files
compressed independently. Totals exclude the manifest itself; LLGo's artifact
report includes its actual size. Include it when measuring a deployment that
ships the manifest. External debug containers contain the module's standard
sections as well as DWARF, so their size is not just the DWARF payload.

The complete output set is staged before publication. Ordinary publication
failures restore the previous files; an unsuccessful restore retains its backup
and reports the recovery path. The manifest is published last. This is not an
atomic multi-file deployment: serve a versioned directory or verify artifact
hashes before switching readers. Successful builds remove obsolete debug/glue
siblings, and a successful rebuild with both options disabled removes the old
manifest.

## Measurement

Darwin arm64, Go 1.27.2, LLVM 22.1.8, Emscripten 6.0.8 and LLGo Binaryen
`llgo-v132.3`; fixtures `benchmark/binary_size/{cprintf,println,fmtprintf}`.
Both columns use LLGo's default optimization and `-debug-artifact=none`,
without `-s`, development DCE or LTO. The baseline is `057851875`; the final
column adds `-wasm-opt=size -wasm-strip=debug` to that compiler. It does not
include the separate initializer/metadata pruning in PR #2908. All 18 final
products executed successfully in Wasmer or the matching Node runner.

Sizes below are **complete artifact bytes / independently compressed Brotli-11
bytes**, including generated JS, `wasm_fs.js` and the final column's manifest.
There are no deferred runtime assets in these cases. Small browser programs
can have a slightly larger compressed total because of the manifest.

| Profile | Example | Baseline | Final | Raw reduction |
| --- | --- | ---: | ---: | ---: |
| W32 | cprintf | 160,243 / 44,666 | 118,610 / 40,517 | 25.98% |
| W32 | println | 159,933 / 44,678 | 118,358 / 40,387 | 26.00% |
| W32 | fmtprintf | 2,371,607 / 514,578 | 1,937,708 / 495,349 | 18.30% |
| J32 | cprintf | 252,135 / 70,298 | 248,991 / 70,371 | 1.25% |
| J32 | println | 251,679 / 70,215 | 248,508 / 70,129 | 1.26% |
| J32 | fmtprintf | 3,139,290 / 703,680 | 3,072,575 / 700,958 | 2.13% |
| J64 | cprintf | 242,604 / 66,670 | 239,736 / 66,807 | 1.18% |
| J64 | println | 242,194 / 66,485 | 239,296 / 66,574 | 1.20% |
| J64 | fmtprintf | 2,908,050 / 642,098 | 2,848,135 / 640,863 | 2.06% |
3 changes: 3 additions & 0 deletions internal/build/artifact_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ func CollectArtifacts(conf *Config, out *OutFmtDetails) ([]Artifact, error) {
return nil, err
}
}
if err := add(ArtifactRoleDeployment, "wasm-manifest", out.WasmManifest); err != nil {
return nil, err
}
return artifacts, nil
}

Expand Down
11 changes: 11 additions & 0 deletions internal/build/artifact_report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func TestCollectArtifacts(t *testing.T) {
elf := write("app.elf", "elf-data")
dwarf := write("app.debug.wasm", "debug")
pcln := write("app.wasm.pclntab", "pcln")
manifest := write("app.wasm-manifest.json", "manifest")
bin := write("app.bin", "bin")
hex := write("app.hex", "hex-data")

Expand All @@ -41,6 +42,15 @@ func TestCollectArtifacts(t *testing.T) {
out OutFmtDetails
want []Artifact
}{
{
name: "final pipeline manifest",
conf: Config{Goarch: "wasm", DebugArtifactMode: DebugArtifactNone},
out: OutFmtDetails{Out: main, WasmManifest: manifest},
want: []Artifact{
{Role: ArtifactRoleDeployment, Format: "wasm", Path: main, Size: 4},
{Role: ArtifactRoleDeployment, Format: "wasm-manifest", Path: manifest, Size: 8},
},
},
{
name: "embedded",
conf: Config{Goarch: "wasm", DebugArtifactMode: DebugArtifactEmbedded},
Expand Down Expand Up @@ -200,6 +210,7 @@ func TestCollectArtifactsValidation(t *testing.T) {
{name: "missing DWARF", mode: DebugArtifactExternal, out: OutFmtDetails{Out: main, DWARF: main + ".debug.wasm"}, role: ArtifactRoleDebug},
{name: "missing runtime symbols", mode: DebugArtifactNone, out: OutFmtDetails{Out: main, PCLN: main + ".pclntab"}, role: ArtifactRoleRuntimeSymbols},
{name: "missing deployment format", mode: DebugArtifactNone, out: OutFmtDetails{Out: main, Bin: main + ".bin"}, role: ArtifactRoleDeployment},
{name: "missing final manifest", mode: DebugArtifactNone, out: OutFmtDetails{Out: main, WasmManifest: main + ".wasm-manifest.json"}, role: ArtifactRoleDeployment},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := CollectArtifacts(&Config{DebugArtifactMode: tt.mode}, &tt.out)
Expand Down
Loading
Loading