From 1b6395c96242aeb2927124523cbd861e1088b54b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sun, 10 May 2026 09:57:44 -0700 Subject: [PATCH 1/2] interp: bail out of loops that iterate too many times The existing loop guard (errLoopUnrolled) only fires when a loop body emits runtime instructions. Loops that are fully evaluable at compile time, such as inserting thousands of entries into a map, were not caught and could hang the compiler. Add a per-basic-block iteration counter that triggers a recoverable error (errLoopTooLong) when any block is entered more than 1000 times in a single function call. This defers the init function to runtime, which is the same behavior as other interp bailouts. Profiling showed that 83% of CPU time was spent in GC, caused by allocation pressure from the interp memory cloning on each map mutation. The iteration limit avoids this entirely by bailing out before the quadratic cost becomes significant. Performance on the reproducer from #2090 (map init with strconv.Itoa): entries before after 5,000 7.4s 2.1s 10,000 17.5s 2.1s 20,000 48.0s 2.8s 65,536 >180s (OOM) 3.2s --- interp/errors.go | 3 ++- interp/interpreter.go | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/interp/errors.go b/interp/errors.go index f6bfb5368d..37a8e6d7d7 100644 --- a/interp/errors.go +++ b/interp/errors.go @@ -19,6 +19,7 @@ var ( errUnsupportedRuntimeInst = errors.New("interp: unsupported instruction (to be emitted at runtime)") errMapAlreadyCreated = errors.New("interp: map already created") errLoopUnrolled = errors.New("interp: loop unrolled") + errLoopTooLong = errors.New("interp: loop ran too many iterations") ) // This is one of the errors that can be returned from toLLVMValue when the @@ -29,7 +30,7 @@ var errInvalidPtrToIntSize = errors.New("interp: ptrtoint integer size does not func isRecoverableError(err error) bool { return err == errIntegerAsPointer || err == errUnsupportedInst || err == errUnsupportedRuntimeInst || err == errMapAlreadyCreated || - err == errLoopUnrolled || err == errInvalidPtrToIntSize + err == errLoopUnrolled || err == errLoopTooLong || err == errInvalidPtrToIntSize } // ErrorLine is one line in a traceback. The position may be missing. diff --git a/interp/interpreter.go b/interp/interpreter.go index e8f5545d5d..e4fd23fe00 100644 --- a/interp/interpreter.go +++ b/interp/interpreter.go @@ -12,6 +12,14 @@ import ( "tinygo.org/x/go-llvm" ) +// maxInterpBlockEntries is the maximum number of times a single basic block may +// be entered during interpretation of one function call. This limits how far +// the interpreter will unroll or evaluate loops before deferring the init +// function to runtime. A value of 1000 allows reasonable init-time loops (e.g. +// filling 256-element lookup tables) while preventing pathological cases like +// inserting tens of thousands of map entries at compile time. +const maxInterpBlockEntries = 1000 + func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent string) (value, memoryView, *Error) { mem := memoryView{r: r, parent: parentMem} locals := make([]value, len(fn.locals)) @@ -26,6 +34,10 @@ func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent // This is used to prevent unrolling. var runtimeBlocks map[int]struct{} + // Track how many times each basic block has been entered, to detect + // loops that are too expensive to evaluate at compile time. + var blockCounts map[int]int + // Start with the first basic block and the first instruction. // Branch instructions may modify both bb and instIndex when branching. bb := fn.blocks[0] @@ -36,6 +48,18 @@ func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent for instIndex := 0; instIndex < len(bb.instructions); instIndex++ { if instIndex == 0 { // This is the start of a new basic block. + + // Check whether this block has been entered too many times, + // which indicates an expensive loop that should be deferred + // to runtime. + if blockCounts == nil { + blockCounts = make(map[int]int) + } + blockCounts[currentBB]++ + if blockCounts[currentBB] > maxInterpBlockEntries { + return nil, mem, r.errorAt(bb.instructions[0], errLoopTooLong) + } + if len(mem.instructions) != startRTInsts { if _, ok := runtimeBlocks[lastBB]; ok { // This loop has been unrolled. From 04aaaba94d7e896aa468d25a59525eeced9e95b5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 12 May 2026 19:09:57 -0700 Subject: [PATCH 2/2] Add -interp-loop-limit --- builder/build.go | 4 +-- compileopts/options.go | 79 +++++++++++++++++++++--------------------- interp/interp.go | 62 +++++++++++++++++---------------- interp/interp_test.go | 2 +- interp/interpreter.go | 14 ++++---- main.go | 75 ++++++++++++++++++++------------------- 6 files changed, 120 insertions(+), 116 deletions(-) diff --git a/builder/build.go b/builder/build.go index 325f6b4906..4987fdddc6 100644 --- a/builder/build.go +++ b/builder/build.go @@ -489,7 +489,7 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe if pkgInit.IsNil() { panic("init not found for " + pkg.Pkg.Path()) } - err := interp.RunFunc(pkgInit, config.Options.InterpTimeout, config.DumpSSA()) + err := interp.RunFunc(pkgInit, config.Options.InterpTimeout, config.Options.InterpMaxLoopIterations, config.DumpSSA()) if err != nil { return err } @@ -1208,7 +1208,7 @@ func createEmbedObjectFile(data, hexSum, sourceFile, sourceDir, tmpdir string, c // needed to convert a program to its final form. Some transformations are not // optional and must be run as the compiler expects them to run. func optimizeProgram(mod llvm.Module, config *compileopts.Config) error { - err := interp.Run(mod, config.Options.InterpTimeout, config.DumpSSA()) + err := interp.Run(mod, config.Options.InterpTimeout, config.Options.InterpMaxLoopIterations, config.DumpSSA()) if err != nil { return err } diff --git a/compileopts/options.go b/compileopts/options.go index e543dca459..c8f9d292b8 100644 --- a/compileopts/options.go +++ b/compileopts/options.go @@ -21,45 +21,46 @@ var ( // usually passed from the command line, but can also be passed in environment // variables for example. type Options struct { - GOOS string // environment variable - GOARCH string // environment variable - GOARM string // environment variable (only used with GOARCH=arm) - GOMIPS string // environment variable (only used with GOARCH=mips and GOARCH=mipsle) - Directory string // working dir, leave it unset to use the current working dir - Target string - BuildMode string // -buildmode flag - Opt string - GC string - PanicStrategy string - Scheduler string - StackSize uint64 // goroutine stack size (if none could be automatically determined) - Serial string - Work bool // -work flag to print temporary build directory - InterpTimeout time.Duration - PrintIR bool - DumpSSA bool - VerifyIR bool - SkipDWARF bool - PrintCommands func(cmd string, args ...string) `json:"-"` - Semaphore chan struct{} `json:"-"` // -p flag controls cap - Debug bool - Nobounds bool - PrintSizes string - PrintAllocs *regexp.Regexp // regexp string - PrintStacks bool - Tags []string - GlobalValues map[string]map[string]string // map[pkgpath]map[varname]value - TestConfig TestConfig - Programmer string - OpenOCDCommands []string - LLVMFeatures string - Monitor bool - BaudRate int - Timeout time.Duration - WITPackage string // pass through to wasm-tools component embed invocation - WITWorld string // pass through to wasm-tools component embed -w option - ExtLDFlags []string - GoCompatibility bool // enable to check for Go version compatibility + GOOS string // environment variable + GOARCH string // environment variable + GOARM string // environment variable (only used with GOARCH=arm) + GOMIPS string // environment variable (only used with GOARCH=mips and GOARCH=mipsle) + Directory string // working dir, leave it unset to use the current working dir + Target string + BuildMode string // -buildmode flag + Opt string + GC string + PanicStrategy string + Scheduler string + StackSize uint64 // goroutine stack size (if none could be automatically determined) + Serial string + Work bool // -work flag to print temporary build directory + InterpTimeout time.Duration + InterpMaxLoopIterations int + PrintIR bool + DumpSSA bool + VerifyIR bool + SkipDWARF bool + PrintCommands func(cmd string, args ...string) `json:"-"` + Semaphore chan struct{} `json:"-"` // -p flag controls cap + Debug bool + Nobounds bool + PrintSizes string + PrintAllocs *regexp.Regexp // regexp string + PrintStacks bool + Tags []string + GlobalValues map[string]map[string]string // map[pkgpath]map[varname]value + TestConfig TestConfig + Programmer string + OpenOCDCommands []string + LLVMFeatures string + Monitor bool + BaudRate int + Timeout time.Duration + WITPackage string // pass through to wasm-tools component embed invocation + WITWorld string // pass through to wasm-tools component embed -w option + ExtLDFlags []string + GoCompatibility bool // enable to check for Go version compatibility } // Verify performs a validation on the given options, raising an error if options are not valid. diff --git a/interp/interp.go b/interp/interp.go index 30b0872485..424c388a11 100644 --- a/interp/interp.go +++ b/interp/interp.go @@ -19,35 +19,37 @@ const checks = true // runner contains all state related to one interp run. type runner struct { - mod llvm.Module - targetData llvm.TargetData - builder llvm.Builder - pointerSize uint32 // cached pointer size from the TargetData - dataPtrType llvm.Type // often used type so created in advance - uintptrType llvm.Type // equivalent to uintptr in Go - maxAlign int // maximum alignment of an object, alignment of runtime.alloc() result - byteOrder binary.ByteOrder // big-endian or little-endian - debug bool // log debug messages - pkgName string // package name of the currently executing package - functionCache map[llvm.Value]*function // cache of compiled functions - objects []object // slice of objects in memory - globals map[llvm.Value]int // map from global to index in objects slice - start time.Time - timeout time.Duration - callsExecuted uint64 + mod llvm.Module + targetData llvm.TargetData + builder llvm.Builder + pointerSize uint32 // cached pointer size from the TargetData + dataPtrType llvm.Type // often used type so created in advance + uintptrType llvm.Type // equivalent to uintptr in Go + maxAlign int // maximum alignment of an object, alignment of runtime.alloc() result + byteOrder binary.ByteOrder // big-endian or little-endian + debug bool // log debug messages + pkgName string // package name of the currently executing package + functionCache map[llvm.Value]*function // cache of compiled functions + objects []object // slice of objects in memory + globals map[llvm.Value]int // map from global to index in objects slice + start time.Time + timeout time.Duration + maxLoopIterations int + callsExecuted uint64 } -func newRunner(mod llvm.Module, timeout time.Duration, debug bool) *runner { +func newRunner(mod llvm.Module, timeout time.Duration, maxLoopIterations int, debug bool) *runner { r := runner{ - mod: mod, - targetData: llvm.NewTargetData(mod.DataLayout()), - byteOrder: llvmutil.ByteOrder(mod.Target()), - debug: debug, - functionCache: make(map[llvm.Value]*function), - objects: []object{{}}, - globals: make(map[llvm.Value]int), - start: time.Now(), - timeout: timeout, + mod: mod, + targetData: llvm.NewTargetData(mod.DataLayout()), + byteOrder: llvmutil.ByteOrder(mod.Target()), + debug: debug, + functionCache: make(map[llvm.Value]*function), + objects: []object{{}}, + globals: make(map[llvm.Value]int), + start: time.Now(), + timeout: timeout, + maxLoopIterations: maxLoopIterations, } r.pointerSize = uint32(r.targetData.PointerSize()) r.dataPtrType = llvm.PointerType(mod.Context().Int8Type(), 0) @@ -64,8 +66,8 @@ func (r *runner) dispose() { // Run evaluates runtime.initAll function as much as possible at compile time. // Set debug to true if it should print output while running. -func Run(mod llvm.Module, timeout time.Duration, debug bool) error { - r := newRunner(mod, timeout, debug) +func Run(mod llvm.Module, timeout time.Duration, maxLoopIterations int, debug bool) error { + r := newRunner(mod, timeout, maxLoopIterations, debug) defer r.dispose() initAll := mod.NamedFunction("runtime.initAll") @@ -204,10 +206,10 @@ func Run(mod llvm.Module, timeout time.Duration, debug bool) error { // RunFunc evaluates a single package initializer at compile time. // Set debug to true if it should print output while running. -func RunFunc(fn llvm.Value, timeout time.Duration, debug bool) error { +func RunFunc(fn llvm.Value, timeout time.Duration, maxLoopIterations int, debug bool) error { // Create and initialize *runner object. mod := fn.GlobalParent() - r := newRunner(mod, timeout, debug) + r := newRunner(mod, timeout, maxLoopIterations, debug) defer r.dispose() initName := fn.Name() if !strings.HasSuffix(initName, ".init") { diff --git a/interp/interp_test.go b/interp/interp_test.go index f5bdb81882..52ffb95a28 100644 --- a/interp/interp_test.go +++ b/interp/interp_test.go @@ -44,7 +44,7 @@ func runTest(t *testing.T, pathPrefix string) { defer mod.Dispose() // Perform the transform. - err = Run(mod, 10*time.Minute, false) + err = Run(mod, 10*time.Minute, DefaultMaxInterpBlockEntries, false) if err != nil { if err, match := err.(*Error); match { println(err.Error()) diff --git a/interp/interpreter.go b/interp/interpreter.go index e4fd23fe00..4b46df249e 100644 --- a/interp/interpreter.go +++ b/interp/interpreter.go @@ -12,13 +12,11 @@ import ( "tinygo.org/x/go-llvm" ) -// maxInterpBlockEntries is the maximum number of times a single basic block may -// be entered during interpretation of one function call. This limits how far -// the interpreter will unroll or evaluate loops before deferring the init -// function to runtime. A value of 1000 allows reasonable init-time loops (e.g. -// filling 256-element lookup tables) while preventing pathological cases like -// inserting tens of thousands of map entries at compile time. -const maxInterpBlockEntries = 1000 +// DefaultMaxInterpBlockEntries is the default maximum number of times a single +// basic block may be entered during interpretation of one function call. This +// limits how far the interpreter will unroll or evaluate loops before deferring +// the init function to runtime. +const DefaultMaxInterpBlockEntries = 1000 func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent string) (value, memoryView, *Error) { mem := memoryView{r: r, parent: parentMem} @@ -56,7 +54,7 @@ func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent blockCounts = make(map[int]int) } blockCounts[currentBB]++ - if blockCounts[currentBB] > maxInterpBlockEntries { + if r.maxLoopIterations > 0 && blockCounts[currentBB] > r.maxLoopIterations { return nil, mem, r.errorAt(bb.instructions[0], errLoopTooLong) } diff --git a/main.go b/main.go index de7b060f23..b2b9bb7be2 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ import ( "github.com/tinygo-org/tinygo/compileopts" "github.com/tinygo-org/tinygo/diagnostics" "github.com/tinygo-org/tinygo/goenv" + "github.com/tinygo-org/tinygo/interp" "github.com/tinygo-org/tinygo/loader" "golang.org/x/tools/go/buildutil" "tinygo.org/x/espflasher/pkg/espflasher" @@ -1738,6 +1739,7 @@ func main() { serial := flag.String("serial", "", "which serial output to use (none, uart, usb, rtt)") work := flag.Bool("work", false, "print the name of the temporary build directory and do not delete this directory on exit") interpTimeout := flag.Duration("interp-timeout", 180*time.Second, "interp optimization pass timeout") + interpLoopLimit := flag.Int("interp-loop-limit", interp.DefaultMaxInterpBlockEntries, "maximum loop iterations during interp (0 to disable)") var tags buildutil.TagsFlag flag.Var(&tags, "tags", "a space-separated list of extra build tags") target := flag.String("target", "", "chip/board name or JSON target specification file") @@ -1855,42 +1857,43 @@ func main() { } options := &compileopts.Options{ - GOOS: goenv.Get("GOOS"), - GOARCH: goenv.Get("GOARCH"), - GOARM: goenv.Get("GOARM"), - GOMIPS: goenv.Get("GOMIPS"), - Target: *target, - BuildMode: *buildMode, - StackSize: stackSize, - Opt: *opt, - GC: *gc, - PanicStrategy: *panicStrategy, - Scheduler: *scheduler, - Serial: *serial, - Work: *work, - InterpTimeout: *interpTimeout, - PrintIR: *printIR, - DumpSSA: *dumpSSA, - VerifyIR: *verifyIR, - SkipDWARF: *skipDwarf, - Semaphore: make(chan struct{}, *parallelism), - Debug: !*nodebug, - Nobounds: *nobounds, - PrintSizes: *printSize, - PrintStacks: *printStacks, - PrintAllocs: printAllocs, - Tags: []string(tags), - TestConfig: testConfig, - GlobalValues: globalVarValues, - Programmer: *programmer, - OpenOCDCommands: ocdCommands, - LLVMFeatures: *llvmFeatures, - Monitor: *monitor, - BaudRate: *baudrate, - Timeout: *timeout, - WITPackage: witPackage, - WITWorld: witWorld, - GoCompatibility: *gocompatibility, + GOOS: goenv.Get("GOOS"), + GOARCH: goenv.Get("GOARCH"), + GOARM: goenv.Get("GOARM"), + GOMIPS: goenv.Get("GOMIPS"), + Target: *target, + BuildMode: *buildMode, + StackSize: stackSize, + Opt: *opt, + GC: *gc, + PanicStrategy: *panicStrategy, + Scheduler: *scheduler, + Serial: *serial, + Work: *work, + InterpTimeout: *interpTimeout, + InterpMaxLoopIterations: *interpLoopLimit, + PrintIR: *printIR, + DumpSSA: *dumpSSA, + VerifyIR: *verifyIR, + SkipDWARF: *skipDwarf, + Semaphore: make(chan struct{}, *parallelism), + Debug: !*nodebug, + Nobounds: *nobounds, + PrintSizes: *printSize, + PrintStacks: *printStacks, + PrintAllocs: printAllocs, + Tags: []string(tags), + TestConfig: testConfig, + GlobalValues: globalVarValues, + Programmer: *programmer, + OpenOCDCommands: ocdCommands, + LLVMFeatures: *llvmFeatures, + Monitor: *monitor, + BaudRate: *baudrate, + Timeout: *timeout, + WITPackage: witPackage, + WITWorld: witWorld, + GoCompatibility: *gocompatibility, } if *printCommands { options.PrintCommands = printCommand