From 134de98ba52278ba23671e61f39c0386c4e5342d Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Mon, 25 May 2026 18:55:36 +0200 Subject: [PATCH] compiler, runtime: optimize zero-sized allocations Instead of referring to an unused global, use a constant value. This is safe even when using `-gc=none` (since no actual memory gets allocated) which wasn't the case before. It should also reduce binary size by a few bytes for most programs. --- builder/sizes_test.go | 2 +- compiler/gc.go | 7 +++++++ compiler/symbol.go | 2 +- compiler/testdata/gc.ll | 5 ++++- interp/interpreter.go | 2 +- src/runtime/arch_avr.go | 2 ++ src/runtime/arch_cortexm.go | 2 ++ src/runtime/arch_tinygoriscv.go | 2 ++ src/runtime/arch_tinygowasm.go | 7 +++++++ src/runtime/arch_xtensa.go | 2 ++ src/runtime/gc.go | 19 +++++++++++++++++++ src/runtime/gc_blocks.go | 5 +---- src/runtime/gc_boehm.go | 5 +---- src/runtime/os_darwin.go | 2 ++ src/runtime/os_linux.go | 2 ++ src/runtime/os_windows.go | 2 ++ src/runtime/runtime_arm7tdmi.go | 2 ++ src/runtime/runtime_nintendoswitch.go | 5 +++++ 18 files changed, 63 insertions(+), 12 deletions(-) diff --git a/builder/sizes_test.go b/builder/sizes_test.go index 32b80e31f..e7c2b763b 100644 --- a/builder/sizes_test.go +++ b/builder/sizes_test.go @@ -42,7 +42,7 @@ func TestBinarySize(t *testing.T) { // This is a small number of very diverse targets that we want to test. tests := []sizeTest{ // microcontrollers - {"hifive1b", "examples/echo", 3705, 299, 0, 2252}, + {"hifive1b", "examples/echo", 3699, 297, 0, 2252}, {"microbit", "examples/serial", 2736, 356, 8, 2248}, {"wioterminal", "examples/pininterrupt", 7960, 1652, 132, 7480}, diff --git a/compiler/gc.go b/compiler/gc.go index 5c59373a9..b5246e218 100644 --- a/compiler/gc.go +++ b/compiler/gc.go @@ -20,6 +20,13 @@ func (b *builder) createAlloc(sizeValue, layoutValue llvm.Value, align int, comm allocFunc = "alloc_noheap" } + // Allocs that don't allocate anything can return an architecture-specific + // sentinel value. + if !sizeValue.IsAConstantInt().IsNil() && sizeValue.ZExtValue() == 0 { + allocFunc = "alloc_zero" + } + + // Make the runtime call. call := b.createRuntimeCall(allocFunc, []llvm.Value{sizeValue, layoutValue}, comment) if align != 0 { // TODO: make sure all callsites set the correct alignment. diff --git a/compiler/symbol.go b/compiler/symbol.go index 0e78e2ce3..fec98ce3b 100644 --- a/compiler/symbol.go +++ b/compiler/symbol.go @@ -162,7 +162,7 @@ func (c *compilerContext) getFunction(fn *ssa.Function) (llvm.Type, llvm.Value) llvmFn.AddAttributeAtIndex(1, c.ctx.CreateEnumAttribute(llvm.AttributeKindID("nocapture"), 0)) case "machine.keepAliveNoEscape", "machine.unsafeNoEscape": llvmFn.AddAttributeAtIndex(1, c.ctx.CreateEnumAttribute(llvm.AttributeKindID("nocapture"), 0)) - case "runtime.alloc", "runtime.alloc_noheap": + case "runtime.alloc", "runtime.alloc_noheap", "runtime.alloc_zero": // Tell the optimizer that runtime.alloc is an allocator, meaning that it // returns values that are never null and never alias to an existing value. for _, attrName := range []string{"noalias", "nonnull"} { diff --git a/compiler/testdata/gc.ll b/compiler/testdata/gc.ll index 14f9aa8b2..e2b009069 100644 --- a/compiler/testdata/gc.ll +++ b/compiler/testdata/gc.ll @@ -75,7 +75,7 @@ entry: define hidden void @main.newStruct(ptr %context) unnamed_addr #1 { entry: %stackalloc = alloca i8, align 1 - %new = call align 1 ptr @runtime.alloc(i32 0, ptr nonnull inttoptr (i32 3 to ptr), ptr undef) #3 + %new = call align 1 ptr @runtime.alloc_zero(i32 0, ptr nonnull inttoptr (i32 3 to ptr), ptr undef) #3 call void @runtime.trackPointer(ptr nonnull %new, ptr nonnull %stackalloc, ptr undef) #3 store ptr %new, ptr @main.struct1, align 4 %new1 = call align 4 dereferenceable(8) ptr @runtime.alloc(i32 8, ptr nonnull inttoptr (i32 3 to ptr), ptr undef) #3 @@ -93,6 +93,9 @@ entry: ret void } +; Function Attrs: allockind("alloc,zeroed") allocsize(0) +declare noalias nonnull ptr @runtime.alloc_zero(i32, ptr, ptr) #2 + ; Function Attrs: nounwind define hidden ptr @main.newFuncValue(ptr %context) unnamed_addr #1 { entry: diff --git a/interp/interpreter.go b/interp/interpreter.go index 710f3e813..ea6f678c2 100644 --- a/interp/interpreter.go +++ b/interp/interpreter.go @@ -299,7 +299,7 @@ func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent // means that monotonic time in the time package is counted from // time.Time{}.Sub(1), which should be fine. locals[inst.localIndex] = literalValue{uint64(0)} - case callFn.name == "runtime.alloc" || callFn.name == "runtime.alloc_noheap": + case callFn.name == "runtime.alloc" || callFn.name == "runtime.alloc_noheap" || callFn.name == "runtime.alloc_zero": // Allocate heap memory. At compile time, this is instead done // by creating a global variable. diff --git a/src/runtime/arch_avr.go b/src/runtime/arch_avr.go index 251d15435..ffd506ada 100644 --- a/src/runtime/arch_avr.go +++ b/src/runtime/arch_avr.go @@ -9,6 +9,8 @@ const GOARCH = "arm" // avr pretends to be arm // The bitness of the CPU (e.g. 8, 32, 64). const TargetBits = 8 +const zeroSizeAllocPtr uintptr = 16 // points into the register file or I/O sapce (which is memory mapped but shouldn't be accessed directly) + const deferExtraRegs = 1 // the frame pointer (Y register) also needs to be stored const callInstSize = 2 // "call" is 4 bytes, "rcall" is 2 bytes diff --git a/src/runtime/arch_cortexm.go b/src/runtime/arch_cortexm.go index 6ea4a4838..96339ec59 100644 --- a/src/runtime/arch_cortexm.go +++ b/src/runtime/arch_cortexm.go @@ -11,6 +11,8 @@ const GOARCH = "arm" // The bitness of the CPU (e.g. 8, 32, 64). const TargetBits = 32 +const zeroSizeAllocPtr uintptr = 16 // part of the interrupt vector + const deferExtraRegs = 0 const callInstSize = 4 // "bl someFunction" is 4 bytes diff --git a/src/runtime/arch_tinygoriscv.go b/src/runtime/arch_tinygoriscv.go index 921c775a5..d0f6d2cc0 100644 --- a/src/runtime/arch_tinygoriscv.go +++ b/src/runtime/arch_tinygoriscv.go @@ -4,6 +4,8 @@ package runtime import "device/riscv" +const zeroSizeAllocPtr uintptr = 0xffff_fff0 // should be unused on most RISC-V chips + const deferExtraRegs = 0 const callInstSize = 4 // 8 without relaxation, maybe 4 with relaxation diff --git a/src/runtime/arch_tinygowasm.go b/src/runtime/arch_tinygowasm.go index 0f61fe008..cfb8dfef6 100644 --- a/src/runtime/arch_tinygowasm.go +++ b/src/runtime/arch_tinygowasm.go @@ -11,6 +11,13 @@ const GOARCH = "wasm" // The bitness of the CPU (e.g. 8, 32, 64). const TargetBits = 32 +// zeroSizedAlloc a sentinel that gets returned when allocating 0 bytes. +// Using this instead of a constant value since I can't easily find a memory +// location that is definitely not going to end up as a valid pointer. +var zeroSizedAlloc uint8 + +var zeroSizeAllocPtr = &zeroSizedAlloc + const deferExtraRegs = 0 const callInstSize = 1 // unknown and irrelevant (llvm.returnaddress doesn't work), so make something up diff --git a/src/runtime/arch_xtensa.go b/src/runtime/arch_xtensa.go index 557bee9a7..f3192e000 100644 --- a/src/runtime/arch_xtensa.go +++ b/src/runtime/arch_xtensa.go @@ -6,6 +6,8 @@ import "device" const GOARCH = "arm" // xtensa pretends to be arm +const zeroSizeAllocPtr uintptr = 16 // part of early flash: partition table, etc + // The bitness of the CPU (e.g. 8, 32, 64). const TargetBits = 32 diff --git a/src/runtime/gc.go b/src/runtime/gc.go index c8c1706e9..6367d34ef 100644 --- a/src/runtime/gc.go +++ b/src/runtime/gc.go @@ -8,3 +8,22 @@ import "unsafe" // It is used instead of normal alloc in //go:noheap functions, and must either // be optimized away or throw a linker error. func alloc_noheap(size uintptr, layout unsafe.Pointer) unsafe.Pointer + +// Special alloc function that returns a sentinel value that can never be on the +// heap or match any other valid pointer. An alloc(0, xxx) call can be safely +// converted to an alloc_zero(0, xxx) call as an optimization. +// +// It is always a good idea to inline this function, since the result is a +// constant. Marking it as go:inline to be sure even though the compiler should +// already be doing this. +// +//go:inline +func alloc_zero(size uintptr, layout unsafe.Pointer) unsafe.Pointer { + // Returning a constant here is safe, since the Go spec does not require + // multiple zero-sized allocations to be unequal when compared for equality: + // + // > Pointers to distinct zero-size variables may or may not be equal. + // + // Source: https://go.dev/ref/spec#Comparison_operators + return unsafe.Pointer(zeroSizeAllocPtr) +} diff --git a/src/runtime/gc_blocks.go b/src/runtime/gc_blocks.go index 583c05169..5ed88d462 100644 --- a/src/runtime/gc_blocks.go +++ b/src/runtime/gc_blocks.go @@ -58,9 +58,6 @@ var ( gcLock task.PMutex // lock to avoid race conditions on multicore systems ) -// zeroSizedAlloc is just a sentinel that gets returned when allocating 0 bytes. -var zeroSizedAlloc uint8 - // Provide some abstraction over heap blocks. // blockState stores the four states in which a block can be. @@ -405,7 +402,7 @@ func calculateHeapAddresses() { //go:noinline func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer { if size == 0 { - return unsafe.Pointer(&zeroSizedAlloc) + return alloc_zero(size, layout) } if interrupt.In() { diff --git a/src/runtime/gc_boehm.go b/src/runtime/gc_boehm.go index 3b5d2ac06..2b2159f5d 100644 --- a/src/runtime/gc_boehm.go +++ b/src/runtime/gc_boehm.go @@ -26,9 +26,6 @@ import ( const needsStaticHeap = false -// zeroSizedAlloc is just a sentinel that gets returned when allocating 0 bytes. -var zeroSizedAlloc uint8 - var gcLock task.PMutex func initHeap() { @@ -67,7 +64,7 @@ func markCurrentGoroutineStack(sp uintptr) { //go:noinline func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer { if size == 0 { - return unsafe.Pointer(&zeroSizedAlloc) + return alloc_zero(size, layout) } gcLock.Lock() diff --git a/src/runtime/os_darwin.go b/src/runtime/os_darwin.go index b6e56863b..3bc939e81 100644 --- a/src/runtime/os_darwin.go +++ b/src/runtime/os_darwin.go @@ -6,6 +6,8 @@ import "unsafe" const GOOS = "darwin" +const zeroSizeAllocPtr uintptr = 16 // part of the first protected page + const ( // See https://github.com/golang/go/blob/master/src/syscall/zerrors_darwin_amd64.go flag_PROT_READ = 0x1 diff --git a/src/runtime/os_linux.go b/src/runtime/os_linux.go index e76fc27f6..a99a2ad29 100644 --- a/src/runtime/os_linux.go +++ b/src/runtime/os_linux.go @@ -11,6 +11,8 @@ import ( const GOOS = "linux" +const zeroSizeAllocPtr uintptr = 16 // part of the first protected page + const ( // See https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/mman-common.h flag_PROT_READ = 0x1 diff --git a/src/runtime/os_windows.go b/src/runtime/os_windows.go index 4003a2ab3..908ce7052 100644 --- a/src/runtime/os_windows.go +++ b/src/runtime/os_windows.go @@ -4,6 +4,8 @@ import "unsafe" const GOOS = "windows" +const zeroSizeAllocPtr uintptr = 16 // part of the first protected page + //export GetModuleHandleExA func _GetModuleHandleExA(dwFlags uint32, lpModuleName unsafe.Pointer, phModule **exeHeader) bool diff --git a/src/runtime/runtime_arm7tdmi.go b/src/runtime/runtime_arm7tdmi.go index ebab4a23f..4e03407a7 100644 --- a/src/runtime/runtime_arm7tdmi.go +++ b/src/runtime/runtime_arm7tdmi.go @@ -36,6 +36,8 @@ var _sidata [0]byte //go:extern _edata var _edata [0]byte +const zeroSizeAllocPtr uintptr = 16 // points somewhere in the BIOS which is not readable + // Entry point for Go. Initialize all packages and call main.main(). // //export main diff --git a/src/runtime/runtime_nintendoswitch.go b/src/runtime/runtime_nintendoswitch.go index 074e18287..b2d13f86b 100644 --- a/src/runtime/runtime_nintendoswitch.go +++ b/src/runtime/runtime_nintendoswitch.go @@ -4,6 +4,11 @@ package runtime import "unsafe" +// Not sure whether there is anything on this location, but it doesn't look like +// it according to the memory map: +// https://switchbrew.org/wiki/Memory_layout +const zeroSizeAllocPtr uintptr = 16 + const ( // Handles infoTypeTotalMemorySize = 6 // Total amount of memory available for process.