Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/llgo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,14 @@ jobs:
-v -count=1 -timeout=2m ./test/simd/...
done

- name: Test SIMD on JavaScript targets with off, Thin and Full LTO
if: matrix.suite == 'test-command'
shell: bash
env:
LLGO: ${{ runner.temp }}/llgo-bin/llgo
LLGO_BUILD_CACHE: "1"
run: dev/test_wasm_simd.sh

- name: Compare JavaScript call semantics with Go
if: matrix.suite == 'test-command'
shell: bash
Expand Down
35 changes: 35 additions & 0 deletions dev/test_wasm_simd.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash

set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

: "${LLGO:=llgo}"
export GOEXPERIMENT=simd

for profile in gojs emscripten emscripten-memory64; do
(
# Avoid inheriting a host target from the caller for raw GoJS builds.
export GOOS=js GOARCH=wasm CGO_ENABLED=0
target=(-emulator)
if [[ "$profile" != gojs ]]; then
target+=(-target "$profile")
fi
echo "SIMD: $profile O0 boundary"
# The full O0 test binary exceeds Node's per-function local-variable limit.
"$LLGO" run -O0 "${target[@]}" ./test/simd/testdata/boundary
for lto in off thin full; do
echo "SIMD: $profile O2 LTO=$lto"
flags=(-O2)
if [[ "$lto" != off ]]; then
flags+=("-lto=$lto")
fi
"$LLGO" test "${flags[@]}" "${target[@]}" \
-v -count=1 -timeout=2m -pclntab=none ./test/simd/...
done
for lto in thin full; do
echo "SIMD: $profile O3 LTO=$lto boundary"
# O3 argument promotion must preserve the JS SjLj memory bridge.
"$LLGO" run -O3 "-lto=$lto" "${target[@]}" ./test/simd/testdata/boundary
done
)
done
27 changes: 27 additions & 0 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -2959,6 +2959,9 @@ func (c *context) archiver() string {
if ar := os.Getenv("LLGO_AR"); ar != "" {
return ar
}
if ar := c.emscriptenArchiver(); ar != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (perf, pre-existing pattern amplified): archiver() / archiveMerger() are called once per package in createArchiveFile, and on wasm/Emscripten builds emscriptenArchiver() now adds up to ~3 exec.LookPath PATH scans per package (CC, sibling emar, bare emar) on top of the existing llvm-ar scan. The result is deterministic for the lifetime of the context. Consider resolving the archiver once and caching it (guarded with sync.Once, mirroring the existing plan9asmOnce) to avoid hundreds of redundant PATH scans on large builds. Not blocking.

return ar
}
// First check toolchain directory (for cross-compilation)
if llvmAr := siblingTool(c.crossCompile.CC, "llvm-ar"); llvmAr != "" {
return llvmAr
Expand All @@ -2979,6 +2982,9 @@ func (c *context) archiveMerger() (string, error) {
if ar := os.Getenv("LLGO_AR"); ar != "" {
return ar, nil
}
if ar := c.emscriptenArchiver(); ar != "" {
return ar, nil
}
if llvmAr := siblingTool(c.crossCompile.CC, "llvm-ar"); llvmAr != "" {
return llvmAr, nil
}
Expand All @@ -2988,6 +2994,27 @@ func (c *context) archiveMerger() (string, error) {
return "", errors.New("llvm-ar is required to create a flat c-archive")
}

// Both GoJS and the named Emscripten targets build C/C++ through emcc. That
// bitcode can be newer than LLGo's linked LLVM, so use the SDK's emar wrapper
// to select its matching llvm-ar for both objects and MRI merges.
func (c *context) emscriptenArchiver() string {
provider := c.crossCompile.WasmProvider
if provider != crosscompile.WasmProviderGoJS && provider != crosscompile.WasmProviderEmscripten {
return ""
}
if cc, err := exec.LookPath(c.crossCompile.CC); err == nil {
// LookPath handles the SDK's emar.bat via PATHEXT on Windows;
// siblingTool only handles native executables such as llvm-ar.exe.
if ar, err := exec.LookPath(filepath.Join(filepath.Dir(cc), "emar")); err == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this sibling-tool probe diverges from the existing siblingTool() helper used just below for llvm-ar. siblingTool handles the Windows .exe suffix explicitly via os.Stat, whereas exec.LookPath of an absolute path relies on %PATHEXT% semantics. The Emscripten SDK ships emar as emar.bat on Windows, so the two lookups can behave differently and the sibling-emar probe may fall through to the bare emar on PATH (which could be a different SDK than the resolved emcc). Consider reusing siblingTool(cc, "emar") after resolving cc, so Windows handling and the lookup strategy stay aligned with the neighboring llvm-ar logic.

return ar
}
}
if ar, err := exec.LookPath("emar"); err == nil {
return ar
}
return ""
}

func siblingTool(compiler, name string) string {
if compiler == "" {
return ""
Expand Down
72 changes: 72 additions & 0 deletions internal/build/package_archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,75 @@ func TestNormalizeToArchiveFailsWithoutObjectFallback(t *testing.T) {
t.Fatalf("ArchiveFile = %q after failure", pkg.ArchiveFile)
}
}

func TestEmscriptenArchiverUsesSDK(t *testing.T) {
root := t.TempDir()
tool := func(dir, name string) string {
t.Helper()
if runtime.GOOS == "windows" {
if name == "emcc" || name == "emar" {
name += ".bat"
} else {
name += ".exe"
}
}
path := filepath.Join(root, dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("unused tool fixture"), 0755); err != nil {
t.Fatal(err)
}
return path
}
if runtime.GOOS == "windows" {
t.Setenv("PATHEXT", ".EXE;.BAT;.CMD")
}
cc := tool("sdk", "emcc")
emar := tool("sdk", "emar")
hostAR := tool("host", "llvm-ar")
otherEMAR := tool("other-sdk", "emar")
// A different SDK on PATH must not override emcc's sibling emar.
t.Setenv("PATH", filepath.Dir(otherEMAR)+string(os.PathListSeparator)+filepath.Dir(hostAR))
t.Setenv("LLGO_AR", "")
for _, provider := range []crosscompile.WasmProvider{crosscompile.WasmProviderGoJS, crosscompile.WasmProviderEmscripten} {
ctx := &context{buildConf: &Config{Goarch: "wasm"}, crossCompile: crosscompile.Export{CC: cc, WasmProvider: provider}}
if got := ctx.archiver(); got != emar {
t.Fatalf("%s archiver = %q, want SDK emar %q", provider, got, emar)
}
if got, err := ctx.archiveMerger(); err != nil || got != emar {
t.Fatalf("%s MRI archiver = %q, %v; want %q", provider, got, err, emar)
}
}
// A command found through PATH should still select its own SDK first.
t.Setenv("PATH", filepath.Dir(cc)+string(os.PathListSeparator)+filepath.Dir(hostAR))
ctx := &context{crossCompile: crosscompile.Export{CC: filepath.Base(cc), WasmProvider: crosscompile.WasmProviderGoJS}}
if got := ctx.emscriptenArchiver(); got != emar {
t.Fatalf("PATH compiler archiver = %q, want %q", got, emar)
}
// A compiler wrapper outside the SDK can use emar from PATH.
ctx.crossCompile.CC = tool("wrapper", "emcc")
if got := ctx.emscriptenArchiver(); got != emar {
t.Fatalf("wrapped compiler archiver = %q, want %q", got, emar)
}
ctx.crossCompile.WasmProvider = crosscompile.WasmProviderNone
if got := ctx.emscriptenArchiver(); got != "" {
t.Fatalf("non-Emscripten target selected %q", got)
}
t.Setenv("PATH", filepath.Dir(hostAR))
ctx = &context{buildConf: &Config{Goarch: "wasm"}, crossCompile: crosscompile.Export{CC: "missing-emcc", WasmProvider: crosscompile.WasmProviderGoJS}}
if got := ctx.archiver(); got != hostAR {
t.Fatalf("missing SDK fallback = %q, want %q", got, hostAR)
}
if got, err := ctx.archiveMerger(); err != nil || got != hostAR {
t.Fatalf("missing SDK MRI fallback = %q, %v; want %q", got, err, hostAR)
}
t.Setenv("LLGO_AR", hostAR)
ctx = &context{crossCompile: crosscompile.Export{CC: cc, WasmProvider: crosscompile.WasmProviderGoJS}}
if got := ctx.archiver(); got != hostAR {
t.Fatalf("ignored explicit LLGO_AR: %q", got)
}
if got, err := ctx.archiveMerger(); err != nil || got != hostAR {
t.Fatalf("MRI ignored explicit LLGO_AR: %q, %v", got, err)
}
}
6 changes: 5 additions & 1 deletion internal/build/wasm_simd_calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func bridgeEmscriptenSIMDCall(mod llvm.Module, call llvm.Value, id int) {
types, args = append(types, typ), append(args, arg)
}
// Each bridge retains the original call and its complete ABI attributes.
// noinline/optnone keep late optimization from exposing a v128 JS call again.
// noinline/optnone keep late inlining from exposing a v128 JS call again.
bridgeType := llvm.FunctionType(retType, types, false)
bridge := llvm.AddFunction(mod, fmt.Sprintf("__llgo_simd_sjlj.%d", id), bridgeType)
bridge.SetLinkage(llvm.InternalLinkage)
Expand All @@ -174,6 +174,10 @@ func bridgeEmscriptenSIMDCall(mod llvm.Module, call llvm.Value, id int) {
arg := bridge.Param(paramOffset + i)
if typ.TypeKind() == llvm.VectorTypeKind {
arg = b.CreateLoad(typ, arg, "")
// LLVM 22's O3 argument promotion ignores optnone and can turn
// this pointer back into a v128 parameter. A volatile load keeps
// the memory ABI required by the JavaScript SjLj wrapper.
arg.SetVolatile(true)
}
call.SetOperand(i, arg)
}
Expand Down
43 changes: 43 additions & 0 deletions internal/build/wasm_simd_calls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,46 @@ func TestEmscriptenSIMDCallBridgeMode(t *testing.T) {
})
}
}

func TestEmscriptenSIMDCallBridgeArgumentPromotion(t *testing.T) {
const source = `
target datalayout = "e-p:64:64-i64:64-n32:64-S128"
target triple = "wasm64-unknown-emscripten"
declare i32 @setjmp(ptr) returns_twice
declare <4 x float> @callee(<4 x float>)
define <4 x float> @withjmp(ptr %jmp, <4 x float> %x) "target-features"="+simd128" {
%saved = call i32 @setjmp(ptr %jmp)
%v = call <4 x float> @callee(<4 x float> %x)
ret <4 x float> %v
}
`
for _, pipeline := range []string{"cgscc(argpromotion)", "thinlto-pre-link<O3>", "lto-pre-link<O3>"} {
t.Run(pipeline, func(t *testing.T) {
mod := parseWasmAggregateIR(t, source)
ctx := &context{crossCompile: crosscompile.Export{
WasmProvider: crosscompile.WasmProviderEmscripten,
WasmProfile: crosscompile.WasmProfileJ64,
}}
if got := lowerEmscriptenSIMDCalls(ctx, mod); got != 1 {
t.Fatalf("bridges = %d, want 1", got)
}
opts := llvm.NewPassBuilderOptions()
defer opts.Dispose()
if err := mod.RunPasses(pipeline, llvm.TargetMachine{}, opts); err != nil {
t.Fatal(err)
}
if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil {
t.Fatal(err)
}
bridge := mod.NamedFunction("__llgo_simd_sjlj.0")
if bridge.IsNil() || bridge.GlobalValueType().ReturnType().TypeKind() != llvm.VoidTypeKind {
t.Fatalf("lost the memory bridge:\n%s", mod.String())
}
for _, param := range bridge.GlobalValueType().ParamTypes() {
if param.TypeKind() == llvm.VectorTypeKind {
t.Fatalf("late optimization promoted a bridge pointer to v128:\n%s", bridge.String())
}
}
})
}
}
10 changes: 10 additions & 0 deletions internal/crosscompile/crosscompile.go
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,16 @@ func useWithGOARMAndToolchain(goos, goarch, goarm string, forceEspClang bool, le
// before an overflow can overwrite another goroutine's memory.
"-sSTACK_OVERFLOW_CHECK=2",
}...)
if ltoMode.Enabled() {
export.CCFLAGS = append(export.CCFLAGS, ltoMode.ClangFlag())
export.LDFLAGS = append(export.LDFLAGS, ltoMode.ClangFlag())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (consistency): this forwards only the base ClangFlag() (e.g. -flto=thin). The sibling WASI branch additionally emits the LTO linker opt level (-Wl,--lto-O2 via ltoLinkerOptFlag) and exception/feature defaults. If emcc is expected to inject the LTO backend opt-level and feature wiring itself, a brief comment stating that would clarify why this branch intentionally differs from the WASI branch's treatment.

// emcc's -O controls its compile and post-link pipelines, but can
// leave wasm-ld's LTO optimizer at its default O2 (e.g. SDK 6.0.8).
// Set the LTO level explicitly, as for the WASI linker above.
if optFlag := ltoLinkerOptFlag(level); optFlag != "" {
export.LDFLAGS = append(export.LDFLAGS, "-Wl,"+optFlag)
}
}
appendEmscriptenLibffiSearchPath(&export, llgoRoot, wasmProfile)
default:
err = errors.New("unsupported GOOS for WebAssembly: " + goos)
Expand Down
38 changes: 38 additions & 0 deletions internal/crosscompile/crosscompile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1457,3 +1457,41 @@ func TestUseTargetCodegenFlagsOnlyAddedToLDFlagsWithLTO(t *testing.T) {
t.Fatalf("missing full LTO ccflag: %v", fullLTO.CCFLAGS)
}
}

func TestEmscriptenLTOFlags(t *testing.T) {
for _, target := range []string{"", "emscripten", "emscripten-memory64"} {
for _, mode := range []lto.Mode{lto.Off, lto.Thin, lto.Full} {
for _, level := range []optlevel.Level{optlevel.O0, optlevel.O1, optlevel.O2, optlevel.O3, optlevel.Os, optlevel.Oz} {
t.Run(target+"/"+mode.String()+"/"+level.Name(), func(t *testing.T) {
export, err := Use("js", "wasm", target, false, level, mode, false)
if err != nil {
t.Fatal(err)
}
for name, flags := range map[string][]string{"compile": export.CCFLAGS, "link": export.LDFLAGS} {
if !slices.Contains(flags, level.Flag()) {
t.Errorf("%s flags do not preserve %s: %v", name, level, flags)
}
for _, candidate := range []lto.Mode{lto.Thin, lto.Full} {
if slices.Contains(flags, candidate.ClangFlag()) != (mode == candidate) {
t.Errorf("%s flags for %s: incorrect %s in %v", name, mode, candidate.ClangFlag(), flags)
}
}
}
var got []string
for _, flag := range export.LDFLAGS {
if strings.HasPrefix(flag, "-Wl,--lto-O") {
got = append(got, flag)
}
}
var want []string
if mode.Enabled() {
want = []string{"-Wl," + ltoLinkerOptFlag(level)}
}
if !slices.Equal(got, want) {
t.Errorf("LTO optimizer flags = %v, want %v", got, want)
}
})
}
}
}
}
28 changes: 25 additions & 3 deletions test/simd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,26 @@ threads, and standard Wasm exception handling; Wasmer selects an available
backend automatically. The official Go WASI comparison uses Wasmtime.
Emscripten execution requires a compatible SDK and Node.js. The local
qualification used Go 1.27.0, LLVM 22.1.8, Emscripten 6.0.8, and Node 24.19.0.
Existing CI runs native amd64/arm64 and WASI at O0/O2.
CI runs native amd64/arm64 and WASI at O0/O2. The JavaScript matrix runs
GoJS (`GOOS=js GOARCH=wasm`), Emscripten, and Emscripten Memory64 with the
default JavaScript SjLj/Asyncify configuration:

```sh
LLGO=/path/to/llgo dev/test_wasm_simd.sh
```

Each profile runs the O0 boundary executable, the complete O2 test suite
with LTO disabled, ThinLTO, and Full LTO, and the O3 boundary executable with
ThinLTO and Full LTO. The O3 runs guard against late argument promotion
replacing a bridge pointer with a vector parameter. The LTO modes pass `-flto=thin` or
`-flto=full` to both compilation and final linking. The requested `-O` level
also reaches compilation and emcc's post-link pipeline. LTO additionally gets
an explicit `--lto-O0` through `--lto-O3`; `-Os`/`-Oz` use `--lto-O2` and retain
their size attributes and post-link size optimizations. Earlier browser builds
accepted `-lto` but did not forward these driver flags, so their successful
runs did not qualify actual link-time optimization. Archives use the SDK's
`emar` to index bitcode produced by its Clang; `LLGO_AR` remains an explicit
override.

The complete O0 Emscripten test executable exceeds Node's local-variable
limit. A small executable covers SIMD initialization, cross-package calls,
Expand All @@ -72,6 +91,7 @@ recovery, and scheduling at O0 without importing the testing framework:
```sh
GOEXPERIMENT=simd llgo run -O0 -target wasi -emulator ./test/simd/testdata/boundary
GOEXPERIMENT=simd llgo run -O0 -target emscripten -emulator ./test/simd/testdata/boundary
GOEXPERIMENT=simd llgo run -O0 -target emscripten-memory64 -emulator ./test/simd/testdata/boundary
GOEXPERIMENT=simd GOOS=js GOARCH=wasm llgo run -O0 -emulator ./test/simd/testdata/boundary
GOEXPERIMENT=simd llgo run -O2 -lto=thin -target emscripten -emulator ./test/simd/testdata/boundary
GOEXPERIMENT=simd llgo run -O2 -lto=full -target emscripten -emulator ./test/simd/testdata/boundary
Expand All @@ -81,8 +101,10 @@ The default GoJS and Emscripten JavaScript SjLj wrappers cannot carry `v128`. Ca
containing `setjmp` use a memory bridge for vector arguments/results; ordinary
Wasm vector calls retain their vector ABI. These bridges cannot be inlined,
and bodies retaining vector calls cannot be moved into recovery functions by
a later backend/LTO inliner. The earlier LLVM optimization still runs at the
requested level. When compilation and linking select native Wasm SjLj (for
a later backend/LTO inliner. Volatile vector loads in the bridge also prevent
LLVM 22's O3 argument promotion from replacing its pointer parameters with
vectors, even though the bridge is `optnone`. The earlier LLVM optimization
still runs at the requested level. When compilation and linking select native Wasm SjLj (for
example, `EMCC_CFLAGS='-fwasm-exceptions -sSUPPORT_LONGJMP=wasm'`), these
bridges and late-inlining restrictions are unnecessary and omitted. Memory64
IR still uses explicit JS SjLj codegen and retains the bridge.
Expand Down
Loading