interp: bail out of loops that iterate too many times (#5395)

* 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

* Add -interp-loop-limit
This commit is contained in:
Jake Bailey
2026-05-17 11:42:27 -07:00
committed by GitHub
parent e80e7e5c10
commit 89d9e33bca
7 changed files with 138 additions and 109 deletions
+2 -2
View File
@@ -489,7 +489,7 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe
if pkgInit.IsNil() { if pkgInit.IsNil() {
panic("init not found for " + pkg.Pkg.Path()) 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 { if err != nil {
return err 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 // 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. // optional and must be run as the compiler expects them to run.
func optimizeProgram(mod llvm.Module, config *compileopts.Config) error { 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 { if err != nil {
return err return err
} }
+40 -39
View File
@@ -21,45 +21,46 @@ var (
// usually passed from the command line, but can also be passed in environment // usually passed from the command line, but can also be passed in environment
// variables for example. // variables for example.
type Options struct { type Options struct {
GOOS string // environment variable GOOS string // environment variable
GOARCH string // environment variable GOARCH string // environment variable
GOARM string // environment variable (only used with GOARCH=arm) GOARM string // environment variable (only used with GOARCH=arm)
GOMIPS string // environment variable (only used with GOARCH=mips and GOARCH=mipsle) 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 Directory string // working dir, leave it unset to use the current working dir
Target string Target string
BuildMode string // -buildmode flag BuildMode string // -buildmode flag
Opt string Opt string
GC string GC string
PanicStrategy string PanicStrategy string
Scheduler string Scheduler string
StackSize uint64 // goroutine stack size (if none could be automatically determined) StackSize uint64 // goroutine stack size (if none could be automatically determined)
Serial string Serial string
Work bool // -work flag to print temporary build directory Work bool // -work flag to print temporary build directory
InterpTimeout time.Duration InterpTimeout time.Duration
PrintIR bool InterpMaxLoopIterations int
DumpSSA bool PrintIR bool
VerifyIR bool DumpSSA bool
SkipDWARF bool VerifyIR bool
PrintCommands func(cmd string, args ...string) `json:"-"` SkipDWARF bool
Semaphore chan struct{} `json:"-"` // -p flag controls cap PrintCommands func(cmd string, args ...string) `json:"-"`
Debug bool Semaphore chan struct{} `json:"-"` // -p flag controls cap
Nobounds bool Debug bool
PrintSizes string Nobounds bool
PrintAllocs *regexp.Regexp // regexp string PrintSizes string
PrintStacks bool PrintAllocs *regexp.Regexp // regexp string
Tags []string PrintStacks bool
GlobalValues map[string]map[string]string // map[pkgpath]map[varname]value Tags []string
TestConfig TestConfig GlobalValues map[string]map[string]string // map[pkgpath]map[varname]value
Programmer string TestConfig TestConfig
OpenOCDCommands []string Programmer string
LLVMFeatures string OpenOCDCommands []string
Monitor bool LLVMFeatures string
BaudRate int Monitor bool
Timeout time.Duration BaudRate int
WITPackage string // pass through to wasm-tools component embed invocation Timeout time.Duration
WITWorld string // pass through to wasm-tools component embed -w option WITPackage string // pass through to wasm-tools component embed invocation
ExtLDFlags []string WITWorld string // pass through to wasm-tools component embed -w option
GoCompatibility bool // enable to check for Go version compatibility 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. // Verify performs a validation on the given options, raising an error if options are not valid.
+2 -1
View File
@@ -19,6 +19,7 @@ var (
errUnsupportedRuntimeInst = errors.New("interp: unsupported instruction (to be emitted at runtime)") errUnsupportedRuntimeInst = errors.New("interp: unsupported instruction (to be emitted at runtime)")
errMapAlreadyCreated = errors.New("interp: map already created") errMapAlreadyCreated = errors.New("interp: map already created")
errLoopUnrolled = errors.New("interp: loop unrolled") 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 // 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 { func isRecoverableError(err error) bool {
return err == errIntegerAsPointer || err == errUnsupportedInst || return err == errIntegerAsPointer || err == errUnsupportedInst ||
err == errUnsupportedRuntimeInst || err == errMapAlreadyCreated || 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. // ErrorLine is one line in a traceback. The position may be missing.
+32 -30
View File
@@ -19,35 +19,37 @@ const checks = true
// runner contains all state related to one interp run. // runner contains all state related to one interp run.
type runner struct { type runner struct {
mod llvm.Module mod llvm.Module
targetData llvm.TargetData targetData llvm.TargetData
builder llvm.Builder builder llvm.Builder
pointerSize uint32 // cached pointer size from the TargetData pointerSize uint32 // cached pointer size from the TargetData
dataPtrType llvm.Type // often used type so created in advance dataPtrType llvm.Type // often used type so created in advance
uintptrType llvm.Type // equivalent to uintptr in Go uintptrType llvm.Type // equivalent to uintptr in Go
maxAlign int // maximum alignment of an object, alignment of runtime.alloc() result maxAlign int // maximum alignment of an object, alignment of runtime.alloc() result
byteOrder binary.ByteOrder // big-endian or little-endian byteOrder binary.ByteOrder // big-endian or little-endian
debug bool // log debug messages debug bool // log debug messages
pkgName string // package name of the currently executing package pkgName string // package name of the currently executing package
functionCache map[llvm.Value]*function // cache of compiled functions functionCache map[llvm.Value]*function // cache of compiled functions
objects []object // slice of objects in memory objects []object // slice of objects in memory
globals map[llvm.Value]int // map from global to index in objects slice globals map[llvm.Value]int // map from global to index in objects slice
start time.Time start time.Time
timeout time.Duration timeout time.Duration
callsExecuted uint64 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{ r := runner{
mod: mod, mod: mod,
targetData: llvm.NewTargetData(mod.DataLayout()), targetData: llvm.NewTargetData(mod.DataLayout()),
byteOrder: llvmutil.ByteOrder(mod.Target()), byteOrder: llvmutil.ByteOrder(mod.Target()),
debug: debug, debug: debug,
functionCache: make(map[llvm.Value]*function), functionCache: make(map[llvm.Value]*function),
objects: []object{{}}, objects: []object{{}},
globals: make(map[llvm.Value]int), globals: make(map[llvm.Value]int),
start: time.Now(), start: time.Now(),
timeout: timeout, timeout: timeout,
maxLoopIterations: maxLoopIterations,
} }
r.pointerSize = uint32(r.targetData.PointerSize()) r.pointerSize = uint32(r.targetData.PointerSize())
r.dataPtrType = llvm.PointerType(mod.Context().Int8Type(), 0) 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. // Run evaluates runtime.initAll function as much as possible at compile time.
// Set debug to true if it should print output while running. // Set debug to true if it should print output while running.
func Run(mod llvm.Module, timeout time.Duration, debug bool) error { func Run(mod llvm.Module, timeout time.Duration, maxLoopIterations int, debug bool) error {
r := newRunner(mod, timeout, debug) r := newRunner(mod, timeout, maxLoopIterations, debug)
defer r.dispose() defer r.dispose()
initAll := mod.NamedFunction("runtime.initAll") 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. // RunFunc evaluates a single package initializer at compile time.
// Set debug to true if it should print output while running. // 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. // Create and initialize *runner object.
mod := fn.GlobalParent() mod := fn.GlobalParent()
r := newRunner(mod, timeout, debug) r := newRunner(mod, timeout, maxLoopIterations, debug)
defer r.dispose() defer r.dispose()
initName := fn.Name() initName := fn.Name()
if !strings.HasSuffix(initName, ".init") { if !strings.HasSuffix(initName, ".init") {
+1 -1
View File
@@ -44,7 +44,7 @@ func runTest(t *testing.T, pathPrefix string) {
defer mod.Dispose() defer mod.Dispose()
// Perform the transform. // Perform the transform.
err = Run(mod, 10*time.Minute, false) err = Run(mod, 10*time.Minute, DefaultMaxInterpBlockEntries, false)
if err != nil { if err != nil {
if err, match := err.(*Error); match { if err, match := err.(*Error); match {
println(err.Error()) println(err.Error())
+22
View File
@@ -12,6 +12,12 @@ import (
"tinygo.org/x/go-llvm" "tinygo.org/x/go-llvm"
) )
// 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) { func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent string) (value, memoryView, *Error) {
mem := memoryView{r: r, parent: parentMem} mem := memoryView{r: r, parent: parentMem}
locals := make([]value, len(fn.locals)) locals := make([]value, len(fn.locals))
@@ -26,6 +32,10 @@ func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent
// This is used to prevent unrolling. // This is used to prevent unrolling.
var runtimeBlocks map[int]struct{} 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. // Start with the first basic block and the first instruction.
// Branch instructions may modify both bb and instIndex when branching. // Branch instructions may modify both bb and instIndex when branching.
bb := fn.blocks[0] bb := fn.blocks[0]
@@ -36,6 +46,18 @@ func (r *runner) run(fn *function, params []value, parentMem *memoryView, indent
for instIndex := 0; instIndex < len(bb.instructions); instIndex++ { for instIndex := 0; instIndex < len(bb.instructions); instIndex++ {
if instIndex == 0 { if instIndex == 0 {
// This is the start of a new basic block. // 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 r.maxLoopIterations > 0 && blockCounts[currentBB] > r.maxLoopIterations {
return nil, mem, r.errorAt(bb.instructions[0], errLoopTooLong)
}
if len(mem.instructions) != startRTInsts { if len(mem.instructions) != startRTInsts {
if _, ok := runtimeBlocks[lastBB]; ok { if _, ok := runtimeBlocks[lastBB]; ok {
// This loop has been unrolled. // This loop has been unrolled.
+39 -36
View File
@@ -30,6 +30,7 @@ import (
"github.com/tinygo-org/tinygo/compileopts" "github.com/tinygo-org/tinygo/compileopts"
"github.com/tinygo-org/tinygo/diagnostics" "github.com/tinygo-org/tinygo/diagnostics"
"github.com/tinygo-org/tinygo/goenv" "github.com/tinygo-org/tinygo/goenv"
"github.com/tinygo-org/tinygo/interp"
"github.com/tinygo-org/tinygo/loader" "github.com/tinygo-org/tinygo/loader"
"golang.org/x/tools/go/buildutil" "golang.org/x/tools/go/buildutil"
"tinygo.org/x/espflasher/pkg/espflasher" "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)") 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") 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") 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 var tags buildutil.TagsFlag
flag.Var(&tags, "tags", "a space-separated list of extra build tags") flag.Var(&tags, "tags", "a space-separated list of extra build tags")
target := flag.String("target", "", "chip/board name or JSON target specification file") target := flag.String("target", "", "chip/board name or JSON target specification file")
@@ -1855,42 +1857,43 @@ func main() {
} }
options := &compileopts.Options{ options := &compileopts.Options{
GOOS: goenv.Get("GOOS"), GOOS: goenv.Get("GOOS"),
GOARCH: goenv.Get("GOARCH"), GOARCH: goenv.Get("GOARCH"),
GOARM: goenv.Get("GOARM"), GOARM: goenv.Get("GOARM"),
GOMIPS: goenv.Get("GOMIPS"), GOMIPS: goenv.Get("GOMIPS"),
Target: *target, Target: *target,
BuildMode: *buildMode, BuildMode: *buildMode,
StackSize: stackSize, StackSize: stackSize,
Opt: *opt, Opt: *opt,
GC: *gc, GC: *gc,
PanicStrategy: *panicStrategy, PanicStrategy: *panicStrategy,
Scheduler: *scheduler, Scheduler: *scheduler,
Serial: *serial, Serial: *serial,
Work: *work, Work: *work,
InterpTimeout: *interpTimeout, InterpTimeout: *interpTimeout,
PrintIR: *printIR, InterpMaxLoopIterations: *interpLoopLimit,
DumpSSA: *dumpSSA, PrintIR: *printIR,
VerifyIR: *verifyIR, DumpSSA: *dumpSSA,
SkipDWARF: *skipDwarf, VerifyIR: *verifyIR,
Semaphore: make(chan struct{}, *parallelism), SkipDWARF: *skipDwarf,
Debug: !*nodebug, Semaphore: make(chan struct{}, *parallelism),
Nobounds: *nobounds, Debug: !*nodebug,
PrintSizes: *printSize, Nobounds: *nobounds,
PrintStacks: *printStacks, PrintSizes: *printSize,
PrintAllocs: printAllocs, PrintStacks: *printStacks,
Tags: []string(tags), PrintAllocs: printAllocs,
TestConfig: testConfig, Tags: []string(tags),
GlobalValues: globalVarValues, TestConfig: testConfig,
Programmer: *programmer, GlobalValues: globalVarValues,
OpenOCDCommands: ocdCommands, Programmer: *programmer,
LLVMFeatures: *llvmFeatures, OpenOCDCommands: ocdCommands,
Monitor: *monitor, LLVMFeatures: *llvmFeatures,
BaudRate: *baudrate, Monitor: *monitor,
Timeout: *timeout, BaudRate: *baudrate,
WITPackage: witPackage, Timeout: *timeout,
WITWorld: witWorld, WITPackage: witPackage,
GoCompatibility: *gocompatibility, WITWorld: witWorld,
GoCompatibility: *gocompatibility,
} }
if *printCommands { if *printCommands {
options.PrintCommands = printCommand options.PrintCommands = printCommand