From 636d77fcc4c5dbe18078ef8433e2d15d42d126ab Mon Sep 17 00:00:00 2001 From: Dave Cheney Date: Tue, 29 Sep 2026 07:04:37 +1000 Subject: [PATCH 1/2] transform: track escapes through nested aggregate results Escape analysis follows pointers returned by a callee, but stops at extractvalue when the extracted value is an aggregate. This loses pointers inside the aggregate and can place escaping allocations on the stack, leaving callers with pointers to dead stack storage. This was possibly introduced in 27f8ec17. The effect is not limited to multiple return values and single array returns. A slice returned alongside another value, an escape to a package-level variable, a capture by a closure, a value reaching the return through a phi, a non-error interface and multi-level forwarding chains are all affected, at every optimisation level that runs OptimizeAllocs. Follow extracted values whose types contain pointers. Share the recursive type check used by GC root discovery so pointer-free aggregates and zero-length arrays do not cause unnecessary heap allocations. extractvalue was the only unsound arm of the opcode switch: getelementptr, bitcast and insertvalue already recurse, load and icmp are safe, store escapes when the value is the stored operand, ret is gated on allowReturn, and everything else falls through to the conservative default. Binary size is unchanged. The -size=short output is byte-identical for testdata/json.go, testdata/stdlib.go, testdata/map.go, microbit with examples/serial, and pico with examples/machinetest. Expand IR and Go coverage for nested aggregates, forwarding calls, escape destinations and nonescaping uses. Add runtime regressions with volatile stack writes so optimization cannot remove the stack clobber. --- testdata/calls.go | 175 +++++++++++++++ testdata/calls.txt | 9 + transform/allocs.go | 2 +- transform/allocs_test.go | 81 +++++++ transform/gc.go | 22 +- transform/llvm.go | 24 ++ transform/testdata/allocs-aggregate.ll | 297 +++++++++++++++++++++++++ transform/testdata/allocs.ll | 25 +++ transform/testdata/allocs.out.ll | 21 ++ transform/testdata/allocs2.go | 183 +++++++++++++++ transform/testdata/allocs2.out.cover | 15 ++ 11 files changed, 834 insertions(+), 20 deletions(-) create mode 100644 transform/testdata/allocs-aggregate.ll diff --git a/testdata/calls.go b/testdata/calls.go index fb681366fb..f0b35e5c72 100644 --- a/testdata/calls.go +++ b/testdata/calls.go @@ -1,5 +1,7 @@ package main +import "runtime/volatile" + type Thing struct { name string } @@ -87,6 +89,179 @@ func main() { if testDeferElse(false) != 0 { println("else defer returned wrong value") } + + // See https://github.com/tinygo-org/tinygo/pull/5776. + err := multiReturnEscape() + clobberStack() + println("multiple return values:", err.(*multiReturnErr).x[1]) + err = singleArrayEscape() + clobberStack() + println("single array return:", err.(*multiReturnErr).x[1]) + err = forwardedReturnEscape() + clobberStack() + println("forwarded return:", err.(*multiReturnErr).x[1]) + s := sliceReturnEscape() + clobberStack() + println("slice return:", s[1]) + globalReturnEscape() + clobberStack() + println("global escape:", globalEscaped.(*multiReturnErr).x[1]) + fn := closureReturnEscape() + clobberStack() + println("closure escape:", fn()) + err = phiReturnEscape(false) + clobberStack() + println("phi escape:", err.(*multiReturnErr).x[1]) + g := interfaceReturnEscape() + clobberStack() + println("interface return:", g.get()) + err = deepForwardEscape() + clobberStack() + println("deep forward:", err.(*multiReturnErr).x[1]) +} + +type multiReturnErr struct{ x [4]int } + +func (e *multiReturnErr) Error() string { return "multiReturnErr" } + +//go:noinline +func wrapMultiReturn(e *multiReturnErr) (int, error) { return 1, e } + +//go:noinline +func multiReturnEscape() error { + e := &multiReturnErr{} + e.x[1] = 42 + _, err := wrapMultiReturn(e) + return err +} + +//go:noinline +func wrapSingleArrayReturn(e *multiReturnErr) [1]error { return [1]error{e} } + +//go:noinline +func singleArrayEscape() error { + e := &multiReturnErr{} + e.x[1] = 43 + a := wrapSingleArrayReturn(e) + return a[0] +} + +//go:noinline +func forwardMultiReturn(e *multiReturnErr) error { + _, err := wrapMultiReturn(e) + return err +} + +//go:noinline +func forwardedReturnEscape() error { + e := &multiReturnErr{} + e.x[1] = 44 + return forwardMultiReturn(e) +} + +//go:noinline +func wrapSliceReturn(b []int) (int, []int) { return 1, b } + +// The allocation reaches the caller inside the {ptr, len, cap} slice aggregate +// of a multiple return value. +// +//go:noinline +func sliceReturnEscape() []int { + b := make([]int, 4) + b[1] = 45 + _, s := wrapSliceReturn(b) + return s +} + +var globalEscaped error + +// The allocation escapes to a package-level variable rather than through a +// return. +// +//go:noinline +func globalReturnEscape() { + e := &multiReturnErr{} + e.x[1] = 46 + _, err := wrapMultiReturn(e) + globalEscaped = err +} + +// The allocation escapes by being captured in a closure. +// +//go:noinline +func closureReturnEscape() func() int { + e := &multiReturnErr{} + e.x[1] = 47 + _, err := wrapMultiReturn(e) + return func() int { return err.(*multiReturnErr).x[1] } +} + +// The extracted value reaches the return through a phi. +// +//go:noinline +func phiReturnEscape(cond bool) error { + e := &multiReturnErr{} + e.x[1] = 48 + _, err := wrapMultiReturn(e) + if cond { + return nil + } + return err +} + +type multiReturnGetter interface{ get() int } + +func (e *multiReturnErr) get() int { return e.x[1] } + +//go:noinline +func wrapInterfaceReturn(e *multiReturnErr) (int, multiReturnGetter) { return 1, e } + +// The allocation reaches the caller inside a non-error interface value and is +// then called through. +// +//go:noinline +func interfaceReturnEscape() multiReturnGetter { + e := &multiReturnErr{} + e.x[1] = 49 + _, g := wrapInterfaceReturn(e) + return g +} + +//go:noinline +func deepForward1(e *multiReturnErr) (error, int) { + _, err := wrapMultiReturn(e) + return err, 2 +} + +//go:noinline +func deepForward2(e *multiReturnErr) [1]error { + err, _ := deepForward1(e) + return [1]error{err} +} + +//go:noinline +func deepForward3(e *multiReturnErr) error { + a := deepForward2(e) + return a[0] +} + +// The allocation is repacked into a different aggregate shape at each of four +// call levels before reaching the caller. +// +//go:noinline +func deepForwardEscape() error { + e := &multiReturnErr{} + e.x[1] = 50 + return deepForward3(e) +} + +//go:noinline +func clobberStack() { + var a [32]uint32 + for i := range a { + // Keep the stack writes. See https://llvm.org/docs/LangRef.html#volatile-memory-accesses. + volatile.StoreUint32(&a[i], uint32(1000+i)) + } } func runFunc(f func(int), arg int) { diff --git a/testdata/calls.txt b/testdata/calls.txt index 78c07caf4d..6b9921e8c1 100644 --- a/testdata/calls.txt +++ b/testdata/calls.txt @@ -16,3 +16,12 @@ thing inside closure: foo inside fp closure: foo 3 Thing.Print: arg: functional args 1 Thing.Print: named thing arg: functional args 2 +multiple return values: 42 +single array return: 43 +forwarded return: 44 +slice return: 45 +global escape: 46 +closure escape: 47 +phi escape: 48 +interface return: 49 +deep forward: 50 diff --git a/transform/allocs.go b/transform/allocs.go index c4c70be7f3..c646e6f4be 100644 --- a/transform/allocs.go +++ b/transform/allocs.go @@ -218,7 +218,7 @@ func valueEscapesAtImpl(value llvm.Value, allowReturn bool, visiting map[llvm.Va return result } case llvm.ExtractValue: - if use.Type().TypeKind() == llvm.PointerTypeKind { + if typeHasPointers(use.Type()) { if !result.merge(valueEscapesAtImpl(use, allowReturn, visiting)) { return result } diff --git a/transform/allocs_test.go b/transform/allocs_test.go index 83ba90ffe8..32343d2e8a 100644 --- a/transform/allocs_test.go +++ b/transform/allocs_test.go @@ -20,6 +20,87 @@ func TestAllocs(t *testing.T) { }) } +func TestAllocsAggregateEdges(t *testing.T) { + t.Parallel() + + const path = "testdata/allocs-aggregate.ll" + ctx := llvm.NewContext() + defer ctx.Dispose() + ensureTestCacheFreshness(t, path) + buf, err := llvm.NewMemoryBufferFromFile(path) + if err != nil { + t.Fatal(err) + } + mod, err := ctx.ParseIR(buf) + if err != nil { + t.Fatal(err) + } + defer mod.Dispose() + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } + + transform.OptimizeAllocs(mod, nil, 256, nil) + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } + + for name, wantHeap := range map[string]bool{ + "nestedStructReturn": true, + "nestedArrayReturn": true, + "arrayOfStructReturn": true, + "structOfArrayReturn": true, + "singleArrayReturn": true, + "aggregateStore": true, + "pointerStore": true, + "aggregateCall": true, + "unknownCall": true, + "indirectCall": true, + "repackReturn": true, + "forwardReturn": true, + "forwardLoad": false, + "gepReturn": true, + "gepLoad": false, + "aggregatePhi": true, + "aggregateSelect": true, + "recursiveReturn": true, + "duplicateArguments": true, + "nonEscapingScalar": false, + "nonEscapingLoad": false, + "nonEscapingNilCheck": false, + "nonEscapingDiscard": false, + "nonEscapingCall": false, + "pointerFreeStruct": false, + "pointerFreeArray": false, + "emptyPointerArray": false, + } { + t.Run(name, func(t *testing.T) { + fn := mod.NamedFunction(name) + if fn.IsNil() { + t.Fatal("function not found") + } + var heap, stack int + for bb := fn.FirstBasicBlock(); !bb.IsNil(); bb = llvm.NextBasicBlock(bb) { + for inst := bb.FirstInstruction(); !inst.IsNil(); inst = llvm.NextInstruction(inst) { + if !inst.IsACallInst().IsNil() && inst.CalledValue() == mod.NamedFunction("runtime.alloc") { + heap++ + } + if !inst.IsAAllocaInst().IsNil() { + stack++ + } + } + } + want := 0 + if wantHeap { + want = 1 + } + if heap != want || stack != 1-want { + t.Errorf("got %d heap and %d stack allocations, want %d and %d:\n%s", heap, stack, want, 1-want, fn.String()) + } + }) + } +} + // Test with a Go file as input (for more accurate tests). func TestAllocs2(t *testing.T) { t.Parallel() diff --git a/transform/gc.go b/transform/gc.go index d5b08dabf4..824aa235b2 100644 --- a/transform/gc.go +++ b/transform/gc.go @@ -372,7 +372,7 @@ func makeGCGlobalRoots(mod llvm.Module) bool { if strings.HasPrefix(global.Name(), "llvm.") || global.IsGlobalConstant() || global.Initializer().IsNil() || - !gcTypeHasPointers(global.GlobalValueType()) { + !typeHasPointers(global.GlobalValueType()) { continue } roots = appendGCGlobalRootRanges(roots, global, global.GlobalValueType(), targetData, ctx.Int8Type(), uintptrType) @@ -483,14 +483,14 @@ func appendGCGlobalRootOffsets(offsets []uint64, typ llvm.Type, targetData llvm. return append(offsets, baseOffset) case llvm.StructTypeKind: for i, fieldType := range typ.StructElementTypes() { - if gcTypeHasPointers(fieldType) { + if typeHasPointers(fieldType) { fieldOffset := targetData.ElementOffset(typ, i) offsets = appendGCGlobalRootOffsets(offsets, fieldType, targetData, baseOffset+fieldOffset) } } case llvm.ArrayTypeKind: elemType := typ.ElementType() - if gcTypeHasPointers(elemType) { + if typeHasPointers(elemType) { elemSize := targetData.TypeAllocSize(elemType) for i := 0; i < typ.ArrayLength(); i++ { offsets = appendGCGlobalRootOffsets(offsets, elemType, targetData, baseOffset+uint64(i)*elemSize) @@ -500,22 +500,6 @@ func appendGCGlobalRootOffsets(offsets []uint64, typ llvm.Type, targetData llvm. return offsets } -func gcTypeHasPointers(typ llvm.Type) bool { - switch typ.TypeKind() { - case llvm.PointerTypeKind: - return true - case llvm.StructTypeKind: - for _, field := range typ.StructElementTypes() { - if gcTypeHasPointers(field) { - return true - } - } - case llvm.ArrayTypeKind: - return typ.ArrayLength() != 0 && gcTypeHasPointers(typ.ElementType()) - } - return false -} - // markParentFunctions traverses all parent function calls (recursively) and // adds them to the set of marked functions. It only considers function calls: // any other uses of such a function is ignored. diff --git a/transform/llvm.go b/transform/llvm.go index 8bb3749a6c..9575ca3fec 100644 --- a/transform/llvm.go +++ b/transform/llvm.go @@ -30,6 +30,30 @@ func hasUses(value llvm.Value) bool { return !value.FirstUse().IsNil() } +// typeHasPointers returns whether a value of this type can hold a pointer. +// +// Vectors report false. That is precise for the IR TinyGo produces: nothing +// lowers a Go value into a vector of pointers, and OptimizeAllocs runs long +// before any vectorizer. It is also why escape analysis may rely on this: a +// pointer can only reach a vector lane through insertelement, which +// valueEscapesAtImpl treats as an escape. A pass that starts feeding pointer +// vectors to either caller needs a case here first. +func typeHasPointers(typ llvm.Type) bool { + switch typ.TypeKind() { + case llvm.PointerTypeKind: + return true + case llvm.StructTypeKind: + for _, field := range typ.StructElementTypes() { + if typeHasPointers(field) { + return true + } + } + case llvm.ArrayTypeKind: + return typ.ArrayLength() != 0 && typeHasPointers(typ.ElementType()) + } + return false +} + // makeGlobalArray creates a new LLVM global with the given name and integers as // contents, and returns the global and initializer type. // Note that it is left with the default linkage etc., you should set diff --git a/transform/testdata/allocs-aggregate.ll b/transform/testdata/allocs-aggregate.ll new file mode 100644 index 0000000000..2152e376ff --- /dev/null +++ b/transform/testdata/allocs-aggregate.ll @@ -0,0 +1,297 @@ +target datalayout = "e-m:e-p:32:32-i64:64-v128:64:128-a:0:32-n32-S64" +target triple = "armv7m-none-eabi" + +%inner = type { ptr, i32 } +%outer = type { i32, %inner } +%deep = type { i32, { [2 x %inner], i32 } } +%mixed = type { ptr, { i32, i32 }, [2 x i32], [0 x ptr] } + +@sink = global %inner zeroinitializer +@pointerSink = global ptr null + +declare ptr @runtime.alloc(i32, ptr) +declare void @capture(%inner) +declare void @nocapture(ptr nocapture) + +define %outer @wrap(ptr %p) { + %inner = insertvalue %inner { ptr null, i32 7 }, ptr %p, 0 + %outer = insertvalue %outer { i32 3, %inner undef }, %inner %inner, 1 + ret %outer %outer +} + +define %deep @wrapDeep(ptr %p) { + %inner = insertvalue %inner { ptr null, i32 7 }, ptr %p, 0 + %array = insertvalue [2 x %inner] zeroinitializer, %inner %inner, 1 + %middle = insertvalue { [2 x %inner], i32 } zeroinitializer, [2 x %inner] %array, 0 + %outer = insertvalue %deep zeroinitializer, { [2 x %inner], i32 } %middle, 1 + ret %deep %outer +} + +define { i32, [2 x [2 x ptr]] } @wrapArrays(ptr %p) { + %inner = insertvalue [2 x ptr] zeroinitializer, ptr %p, 1 + %array = insertvalue [2 x [2 x ptr]] zeroinitializer, [2 x ptr] %inner, 1 + %outer = insertvalue { i32, [2 x [2 x ptr]] } zeroinitializer, [2 x [2 x ptr]] %array, 1 + ret { i32, [2 x [2 x ptr]] } %outer +} + +define [2 x %inner] @wrapSingleArray(ptr %p) { + %inner = insertvalue %inner zeroinitializer, ptr %p, 0 + %array = insertvalue [2 x %inner] zeroinitializer, %inner %inner, 1 + ret [2 x %inner] %array +} + +define %inner @forward(ptr %p) { + %outer = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %outer, 1 + ret %inner %inner +} + +define void @captureInner(%inner %value) { + store %inner %value, ptr @sink + ret void +} + +define %outer @recursive(ptr %p, i1 %again) { +entry: + br i1 %again, label %recurse, label %end +recurse: + %result = call %outer @recursive(ptr %p, i1 false) + ret %outer %result +end: + %wrapped = call %outer @wrap(ptr %p) + ret %outer %wrapped +} + +define void @captureSecond(%inner %ignored, %inner %captured) { + store %inner %captured, ptr @sink + ret void +} + +define { [2 x %inner], i32 } @nestedStructReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %deep @wrapDeep(ptr %p) + %middle = extractvalue %deep %result, 1 + ret { [2 x %inner], i32 } %middle +} + +define [2 x ptr] @nestedArrayReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call { i32, [2 x [2 x ptr]] } @wrapArrays(ptr %p) + %array = extractvalue { i32, [2 x [2 x ptr]] } %result, 1 + %inner = extractvalue [2 x [2 x ptr]] %array, 1 + ret [2 x ptr] %inner +} + +define %inner @arrayOfStructReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %deep @wrapDeep(ptr %p) + %middle = extractvalue %deep %result, 1 + %array = extractvalue { [2 x %inner], i32 } %middle, 0 + %inner = extractvalue [2 x %inner] %array, 1 + ret %inner %inner +} + +define [2 x %inner] @structOfArrayReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %deep @wrapDeep(ptr %p) + %array = extractvalue %deep %result, 1, 0 + ret [2 x %inner] %array +} + +define %inner @singleArrayReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %array = call [2 x %inner] @wrapSingleArray(ptr %p) + %inner = extractvalue [2 x %inner] %array, 1 + ret %inner %inner +} + +define void @aggregateStore() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + store %inner %inner, ptr @sink + ret void +} + +define void @pointerStore() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %ptr = extractvalue %inner %inner, 0 + store ptr %ptr, ptr @pointerSink + ret void +} + +define void @aggregateCall() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + call void @captureInner(%inner %inner) + ret void +} + +define void @unknownCall() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + call void @capture(%inner %inner) + ret void +} + +define void @indirectCall(ptr %fn) { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + call void %fn(%inner %inner) + ret void +} + +define %outer @repackReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %new = insertvalue %outer zeroinitializer, %inner %inner, 1 + ret %outer %new +} + +define %inner @forwardReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %inner = call %inner @forward(ptr %p) + ret %inner %inner +} + +define i32 @forwardLoad() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %inner = call %inner @forward(ptr %p) + %ptr = extractvalue %inner %inner, 0 + %value = load i32, ptr %ptr + ret i32 %value +} + +define ptr @gepReturn() { + %p = call ptr @runtime.alloc(i32 8, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %ptr = extractvalue %inner %inner, 0 + %element = getelementptr i32, ptr %ptr, i32 1 + ret ptr %element +} + +define i32 @gepLoad() { + %p = call ptr @runtime.alloc(i32 8, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %ptr = extractvalue %inner %inner, 0 + %element = getelementptr i32, ptr %ptr, i32 1 + store i32 42, ptr %element + %value = load i32, ptr %element + ret i32 %value +} + +define %inner @aggregatePhi(i1 %cond) { +entry: + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + br i1 %cond, label %yes, label %no +yes: + br label %end +no: + br label %end +end: + %value = phi %inner [ %inner, %yes ], [ zeroinitializer, %no ] + ret %inner %value +} + +define %inner @aggregateSelect(i1 %cond) { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %value = select i1 %cond, %inner %inner, %inner zeroinitializer + ret %inner %value +} + +define %inner @recursiveReturn() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @recursive(ptr %p, i1 true) + %inner = extractvalue %outer %result, 1 + ret %inner %inner +} + +define void @duplicateArguments() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + call void @captureSecond(%inner %inner, %inner %inner) + ret void +} + +define i32 @nonEscapingScalar() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %deep @wrapDeep(ptr %p) + %middle = extractvalue %deep %result, 1 + %array = extractvalue { [2 x %inner], i32 } %middle, 0 + %inner = extractvalue [2 x %inner] %array, 1 + %value = extractvalue %inner %inner, 1 + ret i32 %value +} + +define i32 @nonEscapingLoad() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %ptr = extractvalue %inner %inner, 0 + %value = load i32, ptr %ptr + ret i32 %value +} + +define i1 @nonEscapingNilCheck() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %ptr = extractvalue %inner %inner, 0 + %value = icmp eq ptr %ptr, null + ret i1 %value +} + +define void @nonEscapingDiscard() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + ret void +} + +define void @nonEscapingCall() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %outer @wrap(ptr %p) + %inner = extractvalue %outer %result, 1 + %ptr = extractvalue %inner %inner, 0 + call void @nocapture(ptr %ptr) + ret void +} + +define %mixed @wrapMixed(ptr %p) { + %result = insertvalue %mixed zeroinitializer, ptr %p, 0 + ret %mixed %result +} + +define { i32, i32 } @pointerFreeStruct() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %mixed @wrapMixed(ptr %p) + %scalar = extractvalue %mixed %result, 1 + ret { i32, i32 } %scalar +} + +define [2 x i32] @pointerFreeArray() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %mixed @wrapMixed(ptr %p) + %scalar = extractvalue %mixed %result, 2 + ret [2 x i32] %scalar +} + +define [0 x ptr] @emptyPointerArray() { + %p = call ptr @runtime.alloc(i32 4, ptr null) + %result = call %mixed @wrapMixed(ptr %p) + %empty = extractvalue %mixed %result, 3 + ret [0 x ptr] %empty +} diff --git a/transform/testdata/allocs.ll b/transform/testdata/allocs.ll index 65d941adf2..e4eb798397 100644 --- a/transform/testdata/allocs.ll +++ b/transform/testdata/allocs.ll @@ -85,6 +85,31 @@ define void @testZeroSizedAlloc() { ret void } +; Return the allocated value from a callee inside a nested aggregate, like a +; Go (int, error) result, and let it escape from the caller through the +; extracted inner aggregate. +define { ptr, ptr } @testEscapingReturnedAggregate() { + %alloc = call align 4 ptr @runtime.alloc(i32 4, ptr inttoptr (i32 3 to ptr)) + %result = call { i32, { ptr, ptr } } @wrapInAggregate(ptr %alloc) + %err = extractvalue { i32, { ptr, ptr } } %result, 1 + ret { ptr, ptr } %err +} + +; Same as above, but only a non-pointer part of the aggregate is used, so the +; allocation doesn't escape. +define i32 @testNonEscapingReturnedAggregate() { + %alloc = call align 4 ptr @runtime.alloc(i32 4, ptr inttoptr (i32 3 to ptr)) + %result = call { i32, { ptr, ptr } } @wrapInAggregate(ptr %alloc) + %n = extractvalue { i32, { ptr, ptr } } %result, 0 + ret i32 %n +} + +define { i32, { ptr, ptr } } @wrapInAggregate(ptr %p) { + %itf = insertvalue { ptr, ptr } { ptr null, ptr undef }, ptr %p, 1 + %result = insertvalue { i32, { ptr, ptr } } { i32 1, { ptr, ptr } undef }, { ptr, ptr } %itf, 1 + ret { i32, { ptr, ptr } } %result +} + declare ptr @escapeIntPtr(ptr) declare ptr @noescapeIntPtr(ptr nocapture) diff --git a/transform/testdata/allocs.out.ll b/transform/testdata/allocs.out.ll index 1353d927b6..42637c9c16 100644 --- a/transform/testdata/allocs.out.ll +++ b/transform/testdata/allocs.out.ll @@ -85,6 +85,27 @@ define void @testZeroSizedAlloc() { ret void } +define { ptr, ptr } @testEscapingReturnedAggregate() { + %alloc = call align 4 ptr @runtime.alloc(i32 4, ptr inttoptr (i32 3 to ptr)) + %result = call { i32, { ptr, ptr } } @wrapInAggregate(ptr %alloc) + %err = extractvalue { i32, { ptr, ptr } } %result, 1 + ret { ptr, ptr } %err +} + +define i32 @testNonEscapingReturnedAggregate() { + %stackalloc = alloca [4 x i8], align 4 + store [4 x i8] zeroinitializer, ptr %stackalloc, align 4 + %result = call { i32, { ptr, ptr } } @wrapInAggregate(ptr %stackalloc) + %n = extractvalue { i32, { ptr, ptr } } %result, 0 + ret i32 %n +} + +define { i32, { ptr, ptr } } @wrapInAggregate(ptr %p) { + %itf = insertvalue { ptr, ptr } { ptr null, ptr undef }, ptr %p, 1 + %result = insertvalue { i32, { ptr, ptr } } { i32 1, { ptr, ptr } undef }, { ptr, ptr } %itf, 1 + ret { i32, { ptr, ptr } } %result +} + declare ptr @escapeIntPtr(ptr) declare ptr @noescapeIntPtr(ptr nocapture) diff --git a/transform/testdata/allocs2.go b/transform/testdata/allocs2.go index b8131d821e..90ea57ec1d 100644 --- a/transform/testdata/allocs2.go +++ b/transform/testdata/allocs2.go @@ -174,3 +174,186 @@ func unsafeNoEscape(ptr unsafe.Pointer) uintptr func keepAliveNoEscape(ptr unsafe.Pointer) var pseudoVolatile volatile.Register32 + +type errT struct{ x [4]int } + +func (e *errT) Error() string { return "errT" } + +type ptrStruct struct { + n int + p *errT +} + +func wrapError(e *errT) (int, error) { return 1, e } + +func wrapErrorFirst(e *errT) (error, int) { return e, 1 } + +func wrapAny(e *errT) (int, any) { return 1, e } + +func wrapSlice(b []byte) (int, []byte) { return 1, b } + +func wrapStruct(e *errT) (int, ptrStruct) { return 1, ptrStruct{1, e} } + +func wrapArray(e *errT) (int, [2]*errT) { return 1, [2]*errT{e, nil} } + +var globalErr error + +// The pointer is returned as one of multiple return values, wrapped in an +// interface, slice, struct or array, and then escapes from the caller. +func escapingMultiReturn() error { + e := &errT{} // OUT: escapes at line 206 + _, err := wrapError(e) + return err +} + +func escapingMultiReturnFirst() error { + e := &errT{} // OUT: escapes at line 212 + err, _ := wrapErrorFirst(e) + return err +} + +func escapingMultiReturnAny() any { + e := &errT{} // OUT: escapes at line 218 + _, v := wrapAny(e) + return v +} + +func escapingMultiReturnSlice() []byte { + b := make([]byte, 8) // OUT: escapes at line 224 + _, s := wrapSlice(b) + return s +} + +func escapingMultiReturnStruct() *errT { + e := &errT{} // OUT: escapes at line 195 + _, s := wrapStruct(e) + return s.p +} + +func escapingMultiReturnArray() *errT { + e := &errT{} // OUT: escapes at line 236 + _, a := wrapArray(e) + return a[0] +} + +func escapingMultiReturnGlobal() { + e := &errT{} // OUT: escapes at line 242 + _, err := wrapError(e) + globalErr = err +} + +// The pointer is returned as one of multiple return values, but the caller +// does not let it escape, so it can stay on the stack. +func nonEscapingMultiReturnInt() int { + e := &errT{} + n, _ := wrapError(e) + return n +} + +func nonEscapingMultiReturnNilCheck() bool { + e := &errT{} + _, err := wrapError(e) + return err != nil +} + +func nonEscapingMultiReturnSliceLen() int { + b := make([]byte, 8) + _, s := wrapSlice(b) + return len(s) +} + +func nonEscapingMultiReturnArrayLoad() int { + e := &errT{} + e.x[1] = 42 + _, a := wrapArray(e) + return a[0].x[1] +} + +func wrapSingleArray(e *errT) [1]error { return [1]error{e} } + +func wrapNestedArray(e *errT) (int, [2][2]*errT) { return 1, [2][2]*errT{{}, {nil, e}} } + +func forwardError(e *errT) error { + _, err := wrapError(e) + return err +} + +func escapingSingleArray() error { + e := &errT{} // OUT: escapes at line 284 + a := wrapSingleArray(e) + return a[0] +} + +func escapingNestedArray() [2]*errT { + e := &errT{} // OUT: escapes at line 290 + _, a := wrapNestedArray(e) + return a[1] +} + +func escapingForwardedError() error { + e := &errT{} // OUT: escapes at line 295 + return forwardError(e) +} + +func escapingMultiReturnField() *int { + e := &errT{} // OUT: escapes at line 301 + _, a := wrapArray(e) + return &a[0].x[1] +} + +func escapingMultiReturnCall() { + e := &errT{} // OUT: escapes at line 307 + _, err := wrapError(e) + useInterface(err) +} + +func escapingMultiReturnClosure() func() error { + e := &errT{} // OUT: escapes at line 312 + _, err := wrapError(e) // OUT: escapes at line 313 + return func() error { return err } +} + +func escapingMultiReturnPhi(cond bool) error { + e := &errT{} // OUT: escapes at line 318 + _, err := wrapError(e) + if cond { + err = nil + } + return err +} + +func nonEscapingSingleArray() bool { + e := &errT{} + a := wrapSingleArray(e) + return a[0] != nil +} + +func nonEscapingNestedArray() int { + e := &errT{} + _, a := wrapNestedArray(e) + return a[1][1].x[0] +} + +func nonEscapingForwardedError() bool { + e := &errT{} + return forwardError(e) != nil +} + +func nonEscapingMultiReturnSliceCap() int { + b := make([]byte, 8) + _, s := wrapSlice(b) + return cap(s) +} + +func nonEscapingMultiReturnDiscard() { + e := &errT{} + wrapError(e) +} + +func wrapPointerFreeArray(e *errT) (error, [2]int) { return e, [2]int{e.x[0], 1} } + +func nonEscapingPointerFreeArray() [2]int { + e := &errT{} + _, a := wrapPointerFreeArray(e) + return a +} diff --git a/transform/testdata/allocs2.out.cover b/transform/testdata/allocs2.out.cover index e17f3d421e..77e2592af9 100644 --- a/transform/testdata/allocs2.out.cover +++ b/transform/testdata/allocs2.out.cover @@ -9,3 +9,18 @@ testdata/allocs2.go:50.1,50.36 1 0 testdata/allocs2.go:107.1,107.49 1 0 testdata/allocs2.go:114.1,114.51 1 0 testdata/allocs2.go:128.1,128.55 1 0 +testdata/allocs2.go:204.1,204.42 1 0 +testdata/allocs2.go:210.1,210.42 1 0 +testdata/allocs2.go:216.1,216.42 1 0 +testdata/allocs2.go:222.1,222.50 1 0 +testdata/allocs2.go:228.1,228.42 1 0 +testdata/allocs2.go:234.1,234.42 1 0 +testdata/allocs2.go:240.1,240.42 1 0 +testdata/allocs2.go:282.1,282.42 1 0 +testdata/allocs2.go:288.1,288.42 1 0 +testdata/allocs2.go:294.1,294.42 1 0 +testdata/allocs2.go:299.1,299.42 1 0 +testdata/allocs2.go:305.1,305.42 1 0 +testdata/allocs2.go:311.1,311.52 1 0 +testdata/allocs2.go:312.1,312.52 1 0 +testdata/allocs2.go:317.1,317.42 1 0 From dea9331bdb7ebf4fa150cc63449fd6aa4ec67cd0 Mon Sep 17 00:00:00 2001 From: Dave Cheney Date: Tue, 29 Sep 2026 07:58:30 +1000 Subject: [PATCH 2/2] testdata: exercise the aggregate escape phi at runtime Merge the extracted interface with nil before returning it. Load the branch condition through volatile memory and exercise both outcomes so the compiler cannot fold the regression into a direct return. Check that the runtime fixture returns a phi with an extracted aggregate input before escape analysis, and that its allocation remains on the heap. --- testdata/calls.go | 9 ++++--- testdata/calls.txt | 1 + transform/allocs_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/testdata/calls.go b/testdata/calls.go index f0b35e5c72..c28f4d6144 100644 --- a/testdata/calls.go +++ b/testdata/calls.go @@ -109,9 +109,12 @@ func main() { fn := closureReturnEscape() clobberStack() println("closure escape:", fn()) - err = phiReturnEscape(false) + var phiCond uint32 + err = phiReturnEscape(volatile.LoadUint32(&phiCond) != 0) clobberStack() println("phi escape:", err.(*multiReturnErr).x[1]) + volatile.StoreUint32(&phiCond, 1) + println("phi nil:", phiReturnEscape(volatile.LoadUint32(&phiCond) != 0) == nil) g := interfaceReturnEscape() clobberStack() println("interface return:", g.get()) @@ -196,15 +199,13 @@ func closureReturnEscape() func() int { return func() int { return err.(*multiReturnErr).x[1] } } -// The extracted value reaches the return through a phi. -// //go:noinline func phiReturnEscape(cond bool) error { e := &multiReturnErr{} e.x[1] = 48 _, err := wrapMultiReturn(e) if cond { - return nil + err = nil } return err } diff --git a/testdata/calls.txt b/testdata/calls.txt index 6b9921e8c1..cdc786df2e 100644 --- a/testdata/calls.txt +++ b/testdata/calls.txt @@ -23,5 +23,6 @@ slice return: 45 global escape: 46 closure escape: 47 phi escape: 48 +phi nil: true interface return: 49 deep forward: 50 diff --git a/transform/allocs_test.go b/transform/allocs_test.go index 32343d2e8a..f042877175 100644 --- a/transform/allocs_test.go +++ b/transform/allocs_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/tinygo-org/tinygo/compiler/llvmutil" "github.com/tinygo-org/tinygo/transform" "tinygo.org/x/go-llvm" ) @@ -101,6 +102,63 @@ func TestAllocsAggregateEdges(t *testing.T) { } } +func TestAllocsRuntimePhi(t *testing.T) { + t.Parallel() + + mod := compileGoFileForTesting(t, "../testdata/calls.go") + defer mod.Context().Dispose() + defer mod.Dispose() + po := llvm.NewPassBuilderOptions() + defer po.Dispose() + passes := "globalopt,ipsccp,instcombine,adce,function-attrs" + if llvmutil.Version() >= 18 { + passes = "globalopt,ipsccp,instcombine,adce,function-attrs" + } + if err := mod.RunPasses(passes, llvm.TargetMachine{}, po); err != nil { + t.Fatal(err) + } + fn := mod.NamedFunction("main.phiReturnEscape") + if fn.IsNil() { + t.Fatal("phiReturnEscape not found") + } + var returnedPhi llvm.Value + for bb := fn.FirstBasicBlock(); !bb.IsNil(); bb = llvm.NextBasicBlock(bb) { + for inst := bb.FirstInstruction(); !inst.IsNil(); inst = llvm.NextInstruction(inst) { + if !inst.IsAReturnInst().IsNil() && !inst.Operand(0).IsAPHINode().IsNil() { + returnedPhi = inst.Operand(0) + } + } + } + if returnedPhi.IsNil() { + t.Fatalf("runtime regression does not return a phi before escape analysis:\n%s", fn.String()) + } + var extracted bool + for i := 0; i < returnedPhi.IncomingCount(); i++ { + if !returnedPhi.IncomingValue(i).IsAExtractValueInst().IsNil() { + extracted = true + } + } + if !extracted { + t.Fatalf("returned phi does not merge an extracted aggregate:\n%s", fn.String()) + } + + transform.OptimizeAllocs(mod, nil, 256, nil) + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } + var allocations int + for bb := fn.FirstBasicBlock(); !bb.IsNil(); bb = llvm.NextBasicBlock(bb) { + for inst := bb.FirstInstruction(); !inst.IsNil(); inst = llvm.NextInstruction(inst) { + if !inst.IsACallInst().IsNil() && inst.CalledValue() == mod.NamedFunction("runtime.alloc") { + allocations++ + } + } + } + if allocations != 1 { + t.Fatalf("got %d heap allocations, want 1:\n%s", allocations, fn.String()) + } +} + // Test with a Go file as input (for more accurate tests). func TestAllocs2(t *testing.T) { t.Parallel()