From 3f4a7610005b57ce8c9ff15c6ab95baa8bc0cf8e Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 19:43:27 +0800 Subject: [PATCH 1/4] ssa: apply type patches to nested aggregate fields --- ssa/gcroot_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ ssa/type.go | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/ssa/gcroot_test.go b/ssa/gcroot_test.go index adaa8c3904..44d4e269b2 100644 --- a/ssa/gcroot_test.go +++ b/ssa/gcroot_test.go @@ -134,6 +134,47 @@ func TestAggregateGCRootPointers(t *testing.T) { } } +func TestPatchedNestedGCRootPointers(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + originalPkg := types.NewPackage("syscall/js", "js") + originalName := types.NewTypeName(token.NoPos, originalPkg, "Value", nil) + original := types.NewNamed(originalName, types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, originalPkg, "pointer", types.NewPointer(types.Typ[types.Int]), false), + types.NewField(token.NoPos, originalPkg, "data", types.Typ[types.UnsafePointer], false), + }, nil), nil) + patchedName := types.NewTypeName(token.NoPos, originalPkg, "Value", nil) + patched := types.NewNamed(patchedName, types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, originalPkg, "ref", types.Typ[types.Int32], false), + }, nil), nil) + prog.SetPatch(func(typ types.Type) types.Type { + if typ == original { + return patched + } + return typ + }) + + outer := types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, nil, "value", original, false), + types.NewField(token.NoPos, nil, "err", types.NewInterfaceType(nil, nil).Complete(), false), + }, nil) + param := types.NewParam(token.NoPos, nil, "result", outer) + sig := types.NewSignatureType(nil, nil, nil, types.NewTuple(param), nil, false) + fn := pkg.NewFunc("main.patched", sig, ssa.InGo) + b := fn.MakeBody(1) + + roots := b.GCRootPointers(b.Param(0)) + if len(roots) != 1 { + t.Fatalf("GCRootPointers(patched nested struct) returned %d roots, want 1", len(roots)) + } + b.Return() + b.EndBuild() + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } +} + func assertPanics(t *testing.T, fn func()) { t.Helper() defer func() { diff --git a/ssa/type.go b/ssa/type.go index 98c9fc4848..de4309b2cd 100644 --- a/ssa/type.go +++ b/ssa/type.go @@ -261,7 +261,7 @@ func (p Program) Field(typ Type, i int) Type { } fld = st.Field(i) } - return p.rawType(fld.Type()) + return p.rawType(p.patch(fld.Type())) } func typeStringWithPkg(t types.Type) string { From 430862b95b24c141aedef19f1e5f2abe252d9d7f Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 19:43:40 +0800 Subject: [PATCH 2/4] compiler: add cooperative safepoint polls --- cl/compile.go | 20 ++++++- cl/gcroot.go | 30 +++++++++- cl/safepoint.go | 72 ++++++++++++++++++++++ cl/safepoint_internal_test.go | 78 ++++++++++++++++++++++++ cl/safepoint_test.go | 83 +++++++++++++++++++++++++ internal/safepointplan/plan.go | 63 +++++++++++++++++++ internal/safepointplan/plan_test.go | 93 +++++++++++++++++++++++++++++ ssa/gcroot_test.go | 11 ++++ ssa/package.go | 1 + ssa/safepoint.go | 27 +++++++++ 10 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 cl/safepoint.go create mode 100644 cl/safepoint_internal_test.go create mode 100644 cl/safepoint_test.go create mode 100644 internal/safepointplan/plan.go create mode 100644 internal/safepointplan/plan_test.go create mode 100644 ssa/safepoint.go diff --git a/cl/compile.go b/cl/compile.go index 7139b837ec..83c8a4930f 100644 --- a/cl/compile.go +++ b/cl/compile.go @@ -32,6 +32,7 @@ import ( "github.com/goplus/llgo/cl/blocks" "github.com/goplus/llgo/cl/ssawrap" + "github.com/goplus/llgo/internal/directive" "github.com/goplus/llgo/internal/goembed" "github.com/goplus/llgo/internal/typepatch" "golang.org/x/tools/go/ssa" @@ -181,6 +182,8 @@ type context struct { runtimeCallerFuncs map[*ssa.Function]bool gcRoots map[ssa.Value][]llssa.Expr gcClosureRoot llssa.Expr + safepointEntry bool + safepoints map[ssa.Instruction]struct{} pcLineSeq uint64 patches Patches @@ -617,6 +620,7 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun oldFn, oldGoFn, oldMethodNilDerefChecks, oldCallerFrameMark := p.fn, p.goFn, p.methodNilDerefChecks, p.callerFrameMark oldLocalityFunction := p.locality.function oldGCRoots, oldGCClosureRoot := p.gcRoots, p.gcClosureRoot + oldSafepointEntry, oldSafepoints := p.safepointEntry, p.safepoints p.fn = fn p.goFn = f p.callerFrameMark = llssa.Nil @@ -626,6 +630,7 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun p.fn, p.goFn, p.methodNilDerefChecks, p.callerFrameMark = oldFn, oldGoFn, oldMethodNilDerefChecks, oldCallerFrameMark p.locality.function = oldLocalityFunction p.gcRoots, p.gcClosureRoot = oldGCRoots, oldGCClosureRoot + p.safepointEntry, p.safepoints = oldSafepointEntry, oldSafepoints }() p.phis = nil if dbgSymsEnabled { @@ -646,6 +651,7 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun p.prepareExportedLocalContext(f) p.bvals = make(map[ssa.Value]llssa.Expr) p.methodNilDerefChecks = collectMethodNilDerefChecks(f) + p.prepareCooperativeSafepoints(f, isCgo) p.prepareGCRoots(f, hasCtx) p.initGCRoots(b, f) off := make([]int, len(f.Blocks)) @@ -693,12 +699,16 @@ func funcInfoDisplayName(goName string) string { } func hasNoInlineDirective(f *ssa.Function) bool { + return hasFuncDirective(f, "go:noinline") +} + +func hasFuncDirective(f *ssa.Function, name string) bool { decl, _ := f.Syntax().(*ast.FuncDecl) if decl == nil || decl.Doc == nil { return false } - for _, c := range decl.Doc.List { - if c.Text == "//go:noinline" { + for _, item := range directive.ParseGroup(decl.Doc) { + if item.Name == name { return true } } @@ -871,6 +881,9 @@ func (p *context) compileBlock(b llssa.Builder, block *ssa.BasicBlock, n int, do if enableDbgSyms && block.Parent().Origin() == nil && block.Index == 0 { p.debugParams(b, block.Parent()) } + if block.Index == 0 && p.safepointEntry { + p.emitCooperativeSafepoint(b) + } if doModInit { p.initializeLocalGuards(b) @@ -894,6 +907,9 @@ func (p *context) compileBlock(b llssa.Builder, block *ssa.BasicBlock, n int, do isCgoC2 := isCgoC2func(fnName) isCgoCmacro := isCgoCmacro(fnName) for i, instr := range instrs { + if p.isCooperativeSafepoint(instr) { + p.emitCooperativeSafepoint(b) + } if i == 1 && doModInit && p.state == pkgInPatch { // in patch package but no pkgFNoOldInit initFnNameOld := initFnNameOfHasPatch(p.fn.Name()) fnOld := pkg.NewFunc(initFnNameOld, llssa.NoArgsNoRet, llssa.InC) diff --git a/cl/gcroot.go b/cl/gcroot.go index 587f20b943..eec2c51377 100644 --- a/cl/gcroot.go +++ b/cl/gcroot.go @@ -39,7 +39,15 @@ func (p *context) prepareGCRoots(fn *ssa.Function, hasClosureContext bool) { } typ := p.type_(value.Type(), llssa.InGo) return p.prog.GCRootCount(typ) != 0 - }, gcSafepoint) + }, p.isGCSafepoint) + if p.safepointEntry { + for _, param := range fn.Params { + typ := p.type_(param.Type(), llssa.InGo) + if p.prog.GCRootCount(typ) != 0 { + planned[param] = struct{}{} + } + } + } counts := make(map[ssa.Value]int, len(planned)) total := 0 count := func(value ssa.Value) { @@ -62,7 +70,7 @@ func (p *context) prepareGCRoots(fn *ssa.Function, hasClosureContext bool) { } } } - hasClosureRoot := hasClosureContext && functionHasGCSafepoint(fn) + hasClosureRoot := hasClosureContext && p.functionHasGCSafepoint(fn) if hasClosureRoot { total++ } @@ -117,6 +125,24 @@ func functionHasGCSafepoint(fn *ssa.Function) bool { return false } +func (p *context) functionHasGCSafepoint(fn *ssa.Function) bool { + if p.safepointEntry { + return true + } + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if p.isGCSafepoint(instr) { + return true + } + } + } + return false +} + +func (p *context) isGCSafepoint(instr ssa.Instruction) bool { + return gcSafepoint(instr) || p.isCooperativeSafepoint(instr) +} + // gcSafepoint mirrors the operations whose LLGo lowering can call the runtime. // Unknown instructions stay conservative. func gcSafepoint(instr ssa.Instruction) bool { diff --git a/cl/safepoint.go b/cl/safepoint.go new file mode 100644 index 0000000000..baac7ab483 --- /dev/null +++ b/cl/safepoint.go @@ -0,0 +1,72 @@ +/* + * 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 cl + +import ( + "strings" + + "github.com/goplus/llgo/internal/safepointplan" + llssa "github.com/goplus/llgo/ssa" + "golang.org/x/tools/go/ssa" +) + +func (p *context) prepareCooperativeSafepoints(fn *ssa.Function, isCgo bool) { + p.safepointEntry = false + p.safepoints = nil + if !p.prog.CooperativeSafepointsEnabled() || fn == nil || len(fn.Blocks) == 0 || + isCgo || hasFuncDirective(fn, "go:nosplit") { + return + } + // Package-less SSA wrappers only forward into a declared function and may + // represent runtime helpers, so the declared function owns the poll. + if path := safepointPackagePath(fn); path == "" || excludeSafepointPackage(path) { + return + } + p.safepointEntry = true + p.safepoints = safepointplan.Backedges(fn) +} + +func safepointPackagePath(fn *ssa.Function) string { + for current := fn; current != nil; current = current.Parent() { + if pkg := current.Package(); pkg != nil { + return pkg.Pkg.Path() + } + if origin := current.Origin(); origin != nil { + if pkg := origin.Package(); pkg != nil { + return pkg.Pkg.Path() + } + } + } + return "" +} + +func excludeSafepointPackage(path string) bool { + if path == "runtime" || strings.HasPrefix(path, "internal/runtime/") { + return true + } + runtimeModule := strings.TrimSuffix(llssa.PkgRuntime, "/internal/runtime") + return path == runtimeModule || strings.HasPrefix(path, runtimeModule+"/") +} + +func (p *context) isCooperativeSafepoint(instr ssa.Instruction) bool { + _, ok := p.safepoints[instr] + return ok +} + +func (p *context) emitCooperativeSafepoint(b llssa.Builder) { + b.Call(p.pkg.RuntimeFunc("CooperativeSafepoint")) +} diff --git a/cl/safepoint_internal_test.go b/cl/safepoint_internal_test.go new file mode 100644 index 0000000000..7bb45e2e53 --- /dev/null +++ b/cl/safepoint_internal_test.go @@ -0,0 +1,78 @@ +//go:build !llgo + +/* + * 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 cl + +import ( + "go/types" + "testing" +) + +func TestExcludeSafepointPackage(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {path: "runtime", want: true}, + {path: "internal/runtime/atomic", want: true}, + {path: "github.com/goplus/llgo/runtime", want: true}, + {path: "github.com/goplus/llgo/runtime/internal/wasmevent", want: true}, + {path: "github.com/goplus/llgo/runtimeextra"}, + {path: "example.com/app/runtime"}, + } + for _, test := range tests { + if got := excludeSafepointPackage(test.path); got != test.want { + t.Errorf("excludeSafepointPackage(%q) = %v, want %v", test.path, got, test.want) + } + } +} + +func TestSafepointPackagePathUsesGenericOrigin(t *testing.T) { + pkg := buildLinkOnceSSAPackage(t, `package p +type Box[T any] struct{} +func (Box[T]) M() {} +`) + box := pkg.Pkg.Scope().Lookup("Box").(*types.TypeName).Type().(*types.Named) + boxInt, err := types.Instantiate(nil, box, []types.Type{types.Typ[types.Int]}, true) + if err != nil { + t.Fatal(err) + } + _, fn := linkOnceTestMethodValue(t, pkg, boxInt, "M") + if fn.Package() != nil || fn.Origin() == nil { + t.Fatalf("expected a package-less generic instance, got package=%v origin=%v", fn.Package(), fn.Origin()) + } + if got := safepointPackagePath(fn); got != "p" { + t.Fatalf("safepointPackagePath(%s) = %q, want p", fn, got) + } +} + +func TestSafepointPackagePathLeavesSyntheticWrapperUnowned(t *testing.T) { + pkg := buildLinkOnceSSAPackage(t, `package p +type Inner struct{} +func (Inner) M() {} +type Outer struct{ Inner } +`) + outer := pkg.Pkg.Scope().Lookup("Outer").(*types.TypeName).Type() + _, fn := linkOnceTestMethodValue(t, pkg, types.NewPointer(outer), "M") + if fn.Package() != nil || fn.Origin() != nil { + t.Fatalf("expected an unowned synthetic wrapper, got package=%v origin=%v", fn.Package(), fn.Origin()) + } + if got := safepointPackagePath(fn); got != "" { + t.Fatalf("safepointPackagePath(%s) = %q, want empty", fn, got) + } +} diff --git a/cl/safepoint_test.go b/cl/safepoint_test.go new file mode 100644 index 0000000000..012f8e57e4 --- /dev/null +++ b/cl/safepoint_test.go @@ -0,0 +1,83 @@ +//go:build !llgo + +package cl_test + +import ( + "regexp" + "strings" + "testing" + + "github.com/goplus/llgo/cl/cltest" + llssa "github.com/goplus/llgo/ssa" +) + +func TestCompileCooperativeSafepoints(t *testing.T) { + const src = `package main + +func leaf(p *int) *int { + return p +} + +func loop(p *int, n int) *int { + for n > 0 { + n-- + } + return p +} + +//go:nosplit +func noPoll(p *int) *int { + return p +} +` + ir := cltest.CompileIREx(t, src, "safepoint.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + prog.EnableCooperativeSafepoints(true) + }) + + leaf := findLLVMFunction(t, ir, "leaf") + if got := strings.Count(leaf, "CooperativeSafepoint"); got != 1 { + t.Fatalf("leaf has %d safepoints, want 1:\n%s", got, leaf) + } + if !strings.Contains(leaf, `[1 x ptr]`) { + t.Fatalf("leaf parameter is not rooted across the entry safepoint:\n%s", leaf) + } + + loop := findLLVMFunction(t, ir, "loop") + if got := strings.Count(loop, "CooperativeSafepoint"); got != 2 { + t.Fatalf("loop has %d safepoints, want entry plus backedge:\n%s", got, loop) + } + if !strings.Contains(loop, `[1 x ptr]`) { + t.Fatalf("loop parameter is not rooted across safepoints:\n%s", loop) + } + + noPoll := findLLVMFunction(t, ir, "noPoll") + if strings.Contains(noPoll, "CooperativeSafepoint") || + strings.Contains(noPoll, "llvm_gc_root_chain") { + t.Fatalf("//go:nosplit function contains a safepoint or root frame:\n%s", noPoll) + } +} + +func TestCompileCooperativeSafepointsDisabled(t *testing.T) { + const src = `package main +func loop(n int) { + for n > 0 { + n-- + } +} +` + ir := cltest.CompileIREx(t, src, "safepoint_disabled.go", false, nil) + if strings.Contains(ir, "CooperativeSafepoint") { + t.Fatalf("disabled cooperative safepoints changed ordinary code:\n%s", ir) + } +} + +func findLLVMFunction(t *testing.T, ir, name string) string { + t.Helper() + pattern := regexp.MustCompile(`(?ms)^define [^{]*\.` + regexp.QuoteMeta(name) + `"?\([^)]*\).*?^\}`) + body := pattern.FindString(ir) + if body == "" { + t.Fatalf("LLVM function %s not found:\n%s", name, ir) + } + return body +} diff --git a/internal/safepointplan/plan.go b/internal/safepointplan/plan.go new file mode 100644 index 0000000000..19891b19da --- /dev/null +++ b/internal/safepointplan/plan.go @@ -0,0 +1,63 @@ +/* + * 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 safepointplan identifies control-flow edges that must poll for +// cooperative scheduling. +package safepointplan + +import "golang.org/x/tools/go/ssa" + +// Backedges returns block terminators that close a DFS cycle. Polling before +// these instructions intersects every cycle, including irreducible control +// flow, without adding a poll to every block in a loop. +func Backedges(fn *ssa.Function) map[ssa.Instruction]struct{} { + if fn == nil || len(fn.Blocks) == 0 { + return nil + } + + const ( + unvisited uint8 = iota + visiting + visited + ) + state := make([]uint8, len(fn.Blocks)) + polls := make(map[ssa.Instruction]struct{}) + var visit func(*ssa.BasicBlock) + visit = func(block *ssa.BasicBlock) { + state[block.Index] = visiting + for _, succ := range block.Succs { + switch state[succ.Index] { + case unvisited: + visit(succ) + case visiting: + if n := len(block.Instrs); n != 0 { + polls[block.Instrs[n-1]] = struct{}{} + } + } + } + state[block.Index] = visited + } + + for _, block := range fn.Blocks { + if state[block.Index] == unvisited { + visit(block) + } + } + if len(polls) == 0 { + return nil + } + return polls +} diff --git a/internal/safepointplan/plan_test.go b/internal/safepointplan/plan_test.go new file mode 100644 index 0000000000..91087a9754 --- /dev/null +++ b/internal/safepointplan/plan_test.go @@ -0,0 +1,93 @@ +package safepointplan + +import ( + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +func TestBackedges(t *testing.T) { + tests := []struct { + name string + src string + want int + }{ + { + name: "straight line", + src: `package p; func f(n int) int { return n + 1 }`, + }, + { + name: "loop", + src: `package p; func f(n int) { for n > 0 { n-- } }`, + want: 1, + }, + { + name: "nested loops", + src: `package p; func f(n int) { for i := 0; i < n; i++ { for j := 0; j < n; j++ {} } }`, + want: 2, + }, + { + name: "irreducible loop", + src: `package p +func f(n int) { + if n > 0 { goto left } +right: + n-- + if n > 0 { goto left } + return +left: + n-- + if n > 0 { goto right } +}`, + want: 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fn := buildFunction(t, test.src) + polls := Backedges(fn) + if len(polls) != test.want { + t.Fatalf("Backedges returned %d polls, want %d", len(polls), test.want) + } + for instr := range polls { + switch instr.(type) { + case *ssa.If, *ssa.Jump: + default: + t.Errorf("poll instruction is %T, want a block terminator", instr) + } + } + }) + } +} + +func TestBackedgesNil(t *testing.T) { + if got := Backedges(nil); got != nil { + t.Fatalf("Backedges(nil) = %v, want nil", got) + } +} + +func buildFunction(t *testing.T, src string) *ssa.Function { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "p.go", src, 0) + if err != nil { + t.Fatal(err) + } + pkg, _, err := ssautil.BuildPackage( + &types.Config{Importer: importer.Default()}, + fset, + types.NewPackage("p", "p"), + []*ast.File{file}, + ssa.InstantiateGenerics, + ) + if err != nil { + t.Fatal(err) + } + return pkg.Func("f") +} diff --git a/ssa/gcroot_test.go b/ssa/gcroot_test.go index 44d4e269b2..240cb08720 100644 --- a/ssa/gcroot_test.go +++ b/ssa/gcroot_test.go @@ -13,6 +13,17 @@ import ( "github.com/xgo-dev/llvm" ) +func TestCooperativeSafepointConfiguration(t *testing.T) { + prog := ssatest.NewProgram(t, nil) + if prog.CooperativeSafepointsEnabled() { + t.Fatal("cooperative safepoints enabled by default") + } + prog.EnableCooperativeSafepoints(true) + if !prog.CooperativeSafepointsEnabled() { + t.Fatal("cooperative safepoints remain disabled") + } +} + func TestGCRootFrameIR(t *testing.T) { prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) pkg := prog.NewPackage("main", "main") diff --git a/ssa/package.go b/ssa/package.go index c535eb6e7c..41d7aa15f0 100644 --- a/ssa/package.go +++ b/ssa/package.go @@ -239,6 +239,7 @@ type aProgram struct { enableGoGlobalDCE bool enableDeadcodeDrop bool enableGCRoots bool + enableSafepoints bool disableBoundsChecks bool pthreadStackSize uint64 enableLTOPluginMarker bool diff --git a/ssa/safepoint.go b/ssa/safepoint.go new file mode 100644 index 0000000000..f456c942a9 --- /dev/null +++ b/ssa/safepoint.go @@ -0,0 +1,27 @@ +/* + * 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 ssa + +// EnableCooperativeSafepoints controls compiler-inserted scheduling polls. +func (p Program) EnableCooperativeSafepoints(enable bool) { + p.enableSafepoints = enable +} + +// CooperativeSafepointsEnabled reports whether scheduling polls are enabled. +func (p Program) CooperativeSafepointsEnabled() bool { + return p.enableSafepoints +} From d4b15f13383117c8c11b773eb720326de8060f6d Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 19:43:50 +0800 Subject: [PATCH 3/4] runtime/wasm: schedule through bounded safepoint polls --- runtime/internal/pollbudget/budget.go | 42 +++++++++++++++++++ runtime/internal/pollbudget/budget_test.go | 35 ++++++++++++++++ runtime/internal/runtime/safepoint_stub.go | 23 +++++++++++ runtime/internal/runtime/safepoint_wasm.go | 48 ++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 runtime/internal/pollbudget/budget.go create mode 100644 runtime/internal/pollbudget/budget_test.go create mode 100644 runtime/internal/runtime/safepoint_stub.go create mode 100644 runtime/internal/runtime/safepoint_wasm.go diff --git a/runtime/internal/pollbudget/budget.go b/runtime/internal/pollbudget/budget.go new file mode 100644 index 0000000000..e9248ca1cf --- /dev/null +++ b/runtime/internal/pollbudget/budget.go @@ -0,0 +1,42 @@ +/* + * 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 pollbudget implements a fixed cooperative polling budget. +package pollbudget + +// Budget reports every quantum-th call to Poll. +type Budget struct { + remaining uint32 + quantum uint32 +} + +// New returns a budget with the requested non-zero quantum. +func New(quantum uint32) Budget { + if quantum == 0 { + panic("pollbudget: zero quantum") + } + return Budget{remaining: quantum, quantum: quantum} +} + +// Poll consumes one unit and reports whether the slow path should run. +func (b *Budget) Poll() bool { + if b.remaining > 1 { + b.remaining-- + return false + } + b.remaining = b.quantum + return true +} diff --git a/runtime/internal/pollbudget/budget_test.go b/runtime/internal/pollbudget/budget_test.go new file mode 100644 index 0000000000..acaa3d7d56 --- /dev/null +++ b/runtime/internal/pollbudget/budget_test.go @@ -0,0 +1,35 @@ +package pollbudget + +import "testing" + +func TestBudget(t *testing.T) { + budget := New(3) + if budget.Poll() { + t.Fatal("first poll reached the slow path") + } + if budget.Poll() { + t.Fatal("second poll reached the slow path") + } + if !budget.Poll() { + t.Fatal("third poll did not reach the slow path") + } + if budget.Poll() { + t.Fatal("budget did not reset") + } +} + +func TestZeroQuantum(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("New(0) did not panic") + } + }() + New(0) +} + +func BenchmarkBudgetPoll(b *testing.B) { + budget := New(1024) + for b.Loop() { + budget.Poll() + } +} diff --git a/runtime/internal/runtime/safepoint_stub.go b/runtime/internal/runtime/safepoint_stub.go new file mode 100644 index 0000000000..3f9e2dc119 --- /dev/null +++ b/runtime/internal/runtime/safepoint_stub.go @@ -0,0 +1,23 @@ +//go:build !llgo || !wasm || !llgo_wasm_gc || (wasip1 && llgo.wasi_threads) + +/* + * 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 runtime + +// CooperativeSafepoint is inactive on runtimes without single-worker wasm +// cooperative scheduling. +func CooperativeSafepoint() {} diff --git a/runtime/internal/runtime/safepoint_wasm.go b/runtime/internal/runtime/safepoint_wasm.go new file mode 100644 index 0000000000..8bdd467a39 --- /dev/null +++ b/runtime/internal/runtime/safepoint_wasm.go @@ -0,0 +1,48 @@ +//go:build llgo && wasm && llgo_wasm_gc && !(wasip1 && llgo.wasi_threads) + +/* + * 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 runtime + +import ( + "github.com/goplus/llgo/runtime/internal/pollbudget" + "github.com/goplus/llgo/runtime/internal/wasmevent" +) + +const wasmSafepointQuantum = uint32(1024) + +var wasmSafepointBudget = pollbudget.New(wasmSafepointQuantum) + +// CooperativeSafepoint gives the single wasm worker a bounded opportunity to +// run host events and another runnable goroutine. +func CooperativeSafepoint() { + if !wasmSafepointBudget.Poll() { + return + } + cooperativeSafepointSlow() +} + +//go:noinline +func cooperativeSafepointSlow() { + if !wasmSched.started { + return + } + wasmevent.Poll() + if wasmSched.runq.Len() != 0 { + goschedBackend() + } +} From 158188d7c45a5937563ecbc56be1ce465d0e807b Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 19:44:00 +0800 Subject: [PATCH 4/4] build: enable GC for single-worker wasm by default --- .github/workflows/llgo.yml | 6 +-- internal/build/build.go | 34 ++++++++++---- internal/build/build_test.go | 47 ++++++++++++++----- internal/build/testdata/wasm-gc/abi.c | 8 ++++ internal/build/testdata/wasm-gc/main.go | 33 ++++++++++++- .../internal/runtime/tinygogc/gc_wasm_js.go | 19 +++++++- 6 files changed, 122 insertions(+), 25 deletions(-) diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index c91dd6a769..d97b0b837f 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -508,11 +508,11 @@ jobs: run_wasm_timers "$RUNNER_TEMP/wasm-timers.mjs" GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-timers-wasip1.wasm" ./internal/build/testdata/wasm-timers run_wasi_timers "$RUNNER_TEMP/wasm-timers-wasip1.wasm" - GOOS=js GOARCH=wasm llgo build -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc-go.mjs" ./internal/build/testdata/wasm-gc + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-gc-go.mjs" ./internal/build/testdata/wasm-gc node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc-go.mjs'; await Module();" - llgo build -target wasm -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc.mjs" ./internal/build/testdata/wasm-gc + llgo build -target wasm -o "$RUNNER_TEMP/wasm-gc.mjs" ./internal/build/testdata/wasm-gc node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc.mjs'; await Module();" - GOOS=wasip1 GOARCH=wasm LLGO_WASI_THREADS=0 llgo build -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc-wasip1.wasm" ./internal/build/testdata/wasm-gc + GOOS=wasip1 GOARCH=wasm LLGO_WASI_THREADS=0 llgo build -o "$RUNNER_TEMP/wasm-gc-wasip1.wasm" ./internal/build/testdata/wasm-gc wasm-tools validate --features all "$RUNNER_TEMP/wasm-gc-wasip1.wasm" test "$(wasmtime run -W exceptions=y "$RUNNER_TEMP/wasm-gc-wasip1.wasm" 2>&1)" = "wasm gc ok" file "$RUNNER_TEMP/runtime-js.wasm" \ diff --git a/internal/build/build.go b/internal/build/build.go index 5a4071e102..a0ec84d95f 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -386,7 +386,8 @@ func Build(inv Invocation) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } - if err := configureWasmGC(conf, &export); err != nil { + wasmGC, err := configureWasmGC(conf, &export) + if err != nil { return nil, err } if conf.AppExt == "" { @@ -457,7 +458,8 @@ func Build(inv Invocation) ([]Package, error) { } prog.EnableGoGlobalDCE(conf.goGlobalDCEEnabled()) prog.EnableDeadcodeDrop(conf.deadcodeDropEnabled()) - prog.EnableGCRoots(conf.Goarch == "wasm" && hasBuildTag(conf.Tags, "llgo_wasm_gc")) + prog.EnableGCRoots(wasmGC) + prog.EnableCooperativeSafepoints(wasmGC) if conf.PthreadStackSize > 0 { prog.SetPthreadStackSize(uint64(conf.PthreadStackSize)) } @@ -783,9 +785,13 @@ func defaultBuildTags(goarch, target string) string { return tags } -func configureWasmGC(conf *Config, export *crosscompile.Export) error { - if conf.Goarch != "wasm" || !hasBuildTag(conf.Tags, "llgo_wasm_gc") { - return nil +func configureWasmGC(conf *Config, export *crosscompile.Export) (bool, error) { + explicit := hasBuildTag(conf.Tags, "llgo_wasm_gc") + if conf.Goarch != "wasm" { + if explicit { + return false, fmt.Errorf("llgo_wasm_gc does not support GOARCH=%s", conf.Goarch) + } + return false, nil } switch conf.Goos { case "js": @@ -794,12 +800,24 @@ func configureWasmGC(conf *Config, export *crosscompile.Export) error { } case "wasip1": if IsWasiThreadsEnabled() { - return errors.New("llgo_wasm_gc requires single-worker WASI (set LLGO_WASI_THREADS=0)") + if explicit { + return false, errors.New("llgo_wasm_gc requires single-worker WASI (set LLGO_WASI_THREADS=0)") + } + return false, nil } default: - return fmt.Errorf("llgo_wasm_gc does not support GOOS=%s", conf.Goos) + if explicit { + return false, fmt.Errorf("llgo_wasm_gc does not support GOOS=%s", conf.Goos) + } + return false, nil } - return nil + if !explicit { + if conf.Tags != "" { + conf.Tags += "," + } + conf.Tags += "llgo_wasm_gc" + } + return true, nil } func hasBuildTag(tags, want string) bool { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 7f4dd4b936..e780ef996e 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -325,26 +325,37 @@ func TestEffectiveWasmTypeSizes(t *testing.T) { func TestConfigureWasmGC(t *testing.T) { t.Setenv("LLGO_WASI_THREADS", "0") tests := []struct { - name string - conf Config - want bool - err bool + name string + conf Config + wantGC bool + err bool }{ - {name: "wasm32", conf: Config{Goos: "js", Goarch: "wasm", Tags: "llgo_wasm_gc"}, want: true}, - {name: "comma separated tags", conf: Config{Goos: "js", Goarch: "wasm", Tags: "other,llgo_wasm_gc"}, want: true}, - {name: "default wasm", conf: Config{Goos: "js", Goarch: "wasm"}}, - {name: "WASI", conf: Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"}}, + {name: "wasm32", conf: Config{Goos: "js", Goarch: "wasm", Tags: "llgo_wasm_gc"}, wantGC: true}, + {name: "comma separated tags", conf: Config{Goos: "js", Goarch: "wasm", Tags: "other,llgo_wasm_gc"}, wantGC: true}, + {name: "default wasm", conf: Config{Goos: "js", Goarch: "wasm"}, wantGC: true}, + {name: "WASI", conf: Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"}, wantGC: true}, + {name: "default WASI", conf: Config{Goos: "wasip1", Goarch: "wasm"}, wantGC: true}, + {name: "default with custom tag", conf: Config{Goos: "js", Goarch: "wasm", Tags: "custom"}, wantGC: true}, + {name: "native", conf: Config{Goos: "linux", Goarch: "amd64"}}, + {name: "native explicit", conf: Config{Goos: "linux", Goarch: "amd64", Tags: "llgo_wasm_gc"}, err: true}, + {name: "unsupported host default", conf: Config{Goos: "linux", Goarch: "wasm"}}, {name: "unsupported host", conf: Config{Goos: "linux", Goarch: "wasm", Tags: "llgo_wasm_gc"}, err: true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { export := crosscompile.Export{} - err := configureWasmGC(&test.conf, &export) + enabled, err := configureWasmGC(&test.conf, &export) if (err != nil) != test.err { t.Fatalf("configureWasmGC error = %v, want error %v", err, test.err) } - if got := slices.Contains(export.LDFLAGS, "-sMALLOC=none"); got != test.want { - t.Fatalf("MALLOC=none present = %v, want %v", got, test.want) + if enabled != test.wantGC { + t.Fatalf("configureWasmGC enabled = %v, want %v", enabled, test.wantGC) + } + if got := slices.Contains(export.LDFLAGS, "-sMALLOC=none"); got != (test.wantGC && test.conf.Goos == "js") { + t.Fatalf("MALLOC=none present = %v", got) + } + if test.wantGC && !hasBuildTag(test.conf.Tags, "llgo_wasm_gc") { + t.Fatalf("internal GC tag missing from %q", test.conf.Tags) } }) } @@ -353,11 +364,23 @@ func TestConfigureWasmGC(t *testing.T) { func TestConfigureWasmGCRejectsWASIThreads(t *testing.T) { t.Setenv("LLGO_WASI_THREADS", "1") conf := Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"} - if err := configureWasmGC(&conf, &crosscompile.Export{}); err == nil { + if _, err := configureWasmGC(&conf, &crosscompile.Export{}); err == nil { t.Fatal("expected llgo_wasm_gc with WASI threads to fail") } } +func TestConfigureWasmGCLeavesWASIThreadsDisabled(t *testing.T) { + t.Setenv("LLGO_WASI_THREADS", "1") + conf := Config{Goos: "wasip1", Goarch: "wasm"} + enabled, err := configureWasmGC(&conf, &crosscompile.Export{}) + if err != nil { + t.Fatal(err) + } + if enabled || hasBuildTag(conf.Tags, "llgo_wasm_gc") { + t.Fatalf("threaded WASI selected wasm GC: enabled=%v tags=%q", enabled, conf.Tags) + } +} + func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { runtimeDir := filepath.Join(env.LLGoRuntimeDir(), "internal", "lib", "runtime") for _, goos := range []string{"js", "wasip1"} { diff --git a/internal/build/testdata/wasm-gc/abi.c b/internal/build/testdata/wasm-gc/abi.c index 998721d414..979c9af679 100644 --- a/internal/build/testdata/wasm-gc/abi.c +++ b/internal/build/testdata/wasm-gc/abi.c @@ -1,5 +1,6 @@ #include #include +#include #if defined(__EMSCRIPTEN__) #include @@ -18,6 +19,13 @@ int llgo_test_gc_aligned_alloc(void) { return 0; } emscripten_builtin_free(ptr); + + ptr = NULL; + if (posix_memalign(&ptr, 65536, 257) != 0 || ptr == NULL || + (uintptr_t)ptr % 65536 != 0) { + return 0; + } + free(ptr); #endif return 1; } diff --git a/internal/build/testdata/wasm-gc/main.go b/internal/build/testdata/wasm-gc/main.go index eb7125b34e..f701f0aa06 100644 --- a/internal/build/testdata/wasm-gc/main.go +++ b/internal/build/testdata/wasm-gc/main.go @@ -1,6 +1,10 @@ package main -import "runtime" +import ( + "runtime" + "sync/atomic" + "time" +) type payload struct { value uint64 @@ -10,6 +14,7 @@ var ( globalRoot *payload garbage *payload liveChunks [][]byte + stopLoop atomic.Bool ) func main() { @@ -17,6 +22,7 @@ func main() { panic("aligned allocation failed") } testRoots() + testCooperativeSafepoint() testSuspendedGRoots() testRecoveredRootChain() testReclamation() @@ -24,6 +30,31 @@ func main() { println("wasm gc ok") } +//go:noinline +func cooperativeLoopWorker(ready chan<- struct{}, done chan<- uint64) { + live := &payload{value: 0x31415926} + ready <- struct{}{} + for !stopLoop.Load() { + } + done <- live.value +} + +func testCooperativeSafepoint() { + stopLoop.Store(false) + ready := make(chan struct{}) + done := make(chan uint64) + go cooperativeLoopWorker(ready, done) + <-ready + + time.AfterFunc(10*time.Millisecond, func() { + runtime.GC() + stopLoop.Store(true) + }) + if value := <-done; value != 0x31415926 { + panic("cooperative safepoint lost a live root") + } +} + func testRoots() { globalRoot = &payload{value: 0x12345678} runtime.GC() diff --git a/runtime/internal/runtime/tinygogc/gc_wasm_js.go b/runtime/internal/runtime/tinygogc/gc_wasm_js.go index 50731fff1f..537111b395 100644 --- a/runtime/internal/runtime/tinygogc/gc_wasm_js.go +++ b/runtime/internal/runtime/tinygogc/gc_wasm_js.go @@ -13,7 +13,7 @@ func wasmCalloc(nmemb, size uintptr) unsafe.Pointer { } func wasmMemalign(alignment, size uintptr) unsafe.Pointer { - if alignment < unsafe.Sizeof(uintptr(0)) || alignment&(alignment-1) != 0 { + if !wasmValidMemalign(alignment) { return nil } if alignment <= bytesPerBlock { @@ -25,6 +25,10 @@ func wasmMemalign(alignment, size uintptr) unsafe.Pointer { return unsafe.Pointer(alignUp(uintptr(Alloc(size+alignment-1)), alignment)) } +func wasmValidMemalign(alignment uintptr) bool { + return alignment >= unsafe.Sizeof(uintptr(0)) && alignment&(alignment-1) == 0 +} + //export malloc func malloc(size uintptr) unsafe.Pointer { return Alloc(size) @@ -49,6 +53,19 @@ func memalign(alignment, size uintptr) unsafe.Pointer { return wasmMemalign(alignment, size) } +//export posix_memalign +func posix_memalign(result *unsafe.Pointer, alignment, size uintptr) int32 { + if !wasmValidMemalign(alignment) { + return 22 + } + ptr := wasmMemalign(alignment, size) + if ptr == nil { + return 12 + } + *result = ptr + return 0 +} + //export emscripten_builtin_malloc func emscripten_builtin_malloc(size uintptr) unsafe.Pointer { return Alloc(size)