mirror of
https://github.com/tinygo-org/tinygo.git
synced 2026-08-20 04:19:04 +00:00
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:
+2
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type Options struct {
|
|||||||
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
|
||||||
|
InterpMaxLoopIterations int
|
||||||
PrintIR bool
|
PrintIR bool
|
||||||
DumpSSA bool
|
DumpSSA bool
|
||||||
VerifyIR bool
|
VerifyIR bool
|
||||||
|
|||||||
+2
-1
@@ -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.
|
||||||
|
|||||||
+7
-5
@@ -34,10 +34,11 @@ type runner struct {
|
|||||||
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
|
||||||
|
maxLoopIterations int
|
||||||
callsExecuted uint64
|
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()),
|
||||||
@@ -48,6 +49,7 @@ func newRunner(mod llvm.Module, timeout time.Duration, debug bool) *runner {
|
|||||||
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") {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -1869,6 +1871,7 @@ func main() {
|
|||||||
Serial: *serial,
|
Serial: *serial,
|
||||||
Work: *work,
|
Work: *work,
|
||||||
InterpTimeout: *interpTimeout,
|
InterpTimeout: *interpTimeout,
|
||||||
|
InterpMaxLoopIterations: *interpLoopLimit,
|
||||||
PrintIR: *printIR,
|
PrintIR: *printIR,
|
||||||
DumpSSA: *dumpSSA,
|
DumpSSA: *dumpSSA,
|
||||||
VerifyIR: *verifyIR,
|
VerifyIR: *verifyIR,
|
||||||
|
|||||||
Reference in New Issue
Block a user