Go's Inliner: A Budget of 80 Nodes and the Art of the Cheat Sheet
Go's inliner gives every function an 80-node budget, refuses anything containing defer, go, tail calls or closures, and lifts the ceiling dramatically for hot call sites found by a PGO profile.
TL;DR: Every Go function gets an inline budget of 80 IR nodes. Walk the body, subtract one per node, and if you go negative the function is not inlinable — that’s the “function too complex” message.
defer,goand tail calls disqualify a function outright, and closures cost 15 nodes. Give the compiler a PGO profile and hot call sites get a budget of 2000 instead of 80.
The single most common question about Go performance is “why isn’t this function being inlined?”. The answer is usually a number: the function is cost 87, and the budget is 80.
That budget is not a guideline. It is a hard number in the compiler source, it has been 80 for well over a decade, and understanding how it is spent is most of what you need to write Go that inlines.
There is also a cheat sheet, and it is literally a table. When the new inliner runs, it can dump every call site in the package with the score it computed, the adjustment it applied, and whether the adjustment promoted or demoted the site. That table is how you find out why a particular call did or did not get inlined, and it is the subject of the second half of this article.
This is a walk through how it works, based on reading the Go 1.28
development tree (src/cmd/compile/internal/inline/).
Two passes, not one
The inliner is split into two halves. The header comment says it plainly:
// src/cmd/compile/internal/inline/inl.go:5
// The inlining facility makes 2 passes: first CanInline determines which
// functions are suitable for inlining, and for those that are it
// saves a copy of the body. Then InlineCalls walks each function body to
// expand calls to inlinable functions.
//
// The Debug.l flag controls the aggressiveness. Note that main() swaps level 0 and 1,
// making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and
// are not supported.
// 0: disabled
// 1: 80-nodes leaf functions, oneliners, panic, lazy typechecking (default)
// 2: (unassigned)
// 3: (unassigned)
// 4: allow non-leaf functions
//
// At some point this may get another default and become switch-offable with -N.
//
// The -d typcheckinl flag enables early typechecking of all imported bodies,
// which is useful to flush out bugs.
//
// The Debug.m flag enables diagnostic output. a single -m is useful for verifying
// which calls get inlined or not, more is for debugging, and may go away at any point.So: pass one decides whether a function is inlinable and remembers its cost. Pass two walks each function body and substitutes the bodies of inlinable callees.
The flags are declared in base/flag.go:
// src/cmd/compile/internal/base/flag.go:74
LowerL CountFlag "help:\"disable inlining\""
LowerM CountFlag "help:\"print optimization decisions\""-l=0 disables inlining entirely. -l=1 is the default. -l=4 allows
non-leaf functions (functions that themselves contain calls) to be inlined.
-m prints the decisions.
The budget
Here is the whole cost model, in one constant block:
// src/cmd/compile/internal/inline/inl.go:48
// Inlining budget parameters, gathered in one place
const (
inlineMaxBudget = 80
inlineExtraAppendCost = 0
// default is to inline if there's at most one call. -l=4 overrides this by using 1 instead.
inlineExtraCallCost = 57 // 57 was benchmarked to provided most benefit with no bad surprises; see https://github.com/golang/go/issues/19348#issuecomment-439370742
inlineParamCallCost = 17 // calling a parameter only costs this much extra (inlining might expose a constant function)
inlineExtraPanicCost = 1 // do not penalize inlining panics.
inlineExtraThrowCost = inlineMaxBudget // with current (2018-05/1.11) code, inlining runtime.throw does not help.
inlineBigFunctionNodes = 5000 // Functions with this many nodes are considered "big".
inlineBigFunctionMaxCost = 20 // Max cost of inlinee when inlining into a "big" function.
inlineClosureCalledOnceCost = 10 * inlineMaxBudget // if a closure is just called once, inline it.
)The budget arithmetic, in one picture:
budget = 80
|
| -1 for every IR node
| -57 for a call to a function that will not be inlined
| -17 for a call through a function parameter
| -15 for a closure literal
| -1 for a panic
| +2 for &s.f where f is at offset 0
| +N for refunds from front-end rewrites
v
budget < 0 => "function too complex: cost N exceeds budget 80"That block is worth reading closely, because the numbers encode several years of benchmark-driven decisions.
- 80 is the base budget. Every IR node costs 1.
- 57 is the cost of a call to a function the compiler cannot inline itself. The comment links to the issue where 57 was benchmarked. This is the single most important number after 80: a function containing one non-inlinable call has almost no budget left for anything else.
- 17 is the cheaper cost of a call to a parameter (a function value). Inlining through a parameter can expose a constant function, so it is worth more.
- 1 is the cost of
panic. Panicking is rare and the panic path is cold, so it should not discourage inlining. - 80 (the whole budget) is the cost of
runtime.throw, because inlining it does not help.
Counting the nodes
The counter is hairyVisitor. It walks the IR tree, subtracting from
v.budget as it goes, and returns “too hairy” the moment the budget goes
negative:
// src/cmd/compile/internal/inline/inl.go:476
func (v *hairyVisitor) tooHairy(fn *ir.Func) bool {
v.do = v.doNode // cache closure
if ir.DoChildren(fn, v.do) {
return true
}
if v.budget < 0 {
v.reason = fmt.Sprintf("function too complex: cost %d exceeds budget %d", v.maxBudget-v.budget, v.maxBudget)
return true
}
return false
}And the per-node charge is at the bottom of doNode:
// src/cmd/compile/internal/inline/inl.go:831
v.budget--
// When debugging, don't stop early, to get full cost of inlining this function
if v.budget < 0 && base.Flag.LowerM < 2 && !logopt.Enabled() && !v.debug {
v.reason = "too expensive"
return true
}
return ir.DoChildren(n, v.do)
}The switch above the v.budget-- handles the special cases. A few
examples that show how the model actually behaves:
// src/cmd/compile/internal/inline/inl.go:703
case ir.OGO, ir.ODEFER, ir.OTAILCALL:
v.reason = "unhandled op " + n.Op().String()
return true
case ir.OAPPEND:
v.budget -= inlineExtraAppendCost
case ir.OADDR:
n := n.(*ir.AddrExpr)
// Make "&s.f" cost 0 when f's offset is zero.
if dot, ok := n.X.(*ir.SelectorExpr); ok && (dot.Op() == ir.ODOT || dot.Op() == ir.ODOTPTR) {
if _, ok := dot.X.(*ir.Name); ok && dot.Selection.Offset == 0 {
v.budget += 2 // undo ir.OADDR+ir.ODOT/ir.ODOTPTR
}
}go, defer and tail calls are hard rejects. Not “expensive” — rejected.
There is no budget that will let a function containing a defer be inlined
at any level.
append is free (cost 0), because inlineExtraAppendCost is 0. Taking the
address of a field at offset zero is free, because the address-of and the
field access together are worth exactly one node.
There are also refunds. Some constructs get added back to the budget, because they would otherwise distort the measurement:
// src/cmd/compile/internal/inline/inl.go:771
case ir.OAS2:
n := n.(*ir.AssignListStmt)
// Unified IR unconditionally rewrites:
//
// a, b = f()
//
// into:
//
// DCL tmp1
// DCL tmp2
// tmp1, tmp2 = f()
// a, b = tmp1, tmp2
//
// so that it can insert implicit conversions as necessary. To
// minimize impact to the existing inlining heuristics (in
// particular, to avoid breaking the existing inlinability regress
// tests), we need to compensate for this here.
//
// See also identical logic in IsBigFunc.
if len(n.Rhs) > 0 {
if init := n.Rhs[0].Init(); len(init) == 1 {
if _, ok := init[0].(*ir.AssignListStmt); ok {
// 4 for each value, because each temporary variable now
// appears 3 times (DCL, LHS, RHS), plus an extra DCL node.
//
// 1 for the extra "tmp1, tmp2 = f()" assignment statement.
v.budget += 4*int32(len(n.Lhs)) + 1
}
}
}This is a nice illustration of how the cost model is a proxy. The cost is not “how much machine code will this generate”; it is “how many IR nodes did the front end hand me”, with hand-tuned corrections so that front-end rewrites do not penalise the programmer.
What disqualifies a function outright
Two separate mechanisms. The first is InlineImpossible, which returns a
reason string before any costing happens at all:
// src/cmd/compile/internal/inline/inl.go:347
// InlineImpossible returns a non-empty reason string if fn is impossible to
// inline regardless of cost or contents.
func InlineImpossible(fn *ir.Func) string {
var reason string // reason, if any, that the function can not be inlined.
if fn.Nname == nil {
reason = "no name"
return reason
}
// If marked "go:noinline", don't inline.
if fn.Pragma&ir.Noinline != 0 {
reason = "marked go:noinline"
return reason
}The pragma itself is defined in ir/node.go:
// src/cmd/compile/internal/ir/node.go:435
const (
// Func pragmas.
Nointerface PragmaFlag = 1 << iota
Noescape // func parameters don't escape
Norace // func must not have race detector annotations
Nosplit // func should not execute on separate stack
Noinline // func should not be inlined
NoCheckPtr // func should not be instrumented by checkptr
CgoUnsafeArgs // treat a pointer to one arg as a pointer to them all
UintptrKeepAlive // pointers converted to uintptr must be kept alive
UintptrEscapes // pointers converted to uintptr escape//go:noinline is the pragma you will actually use. There is no
//go:inline in the compiler: the opt-in direction is handled by a
different mechanism entirely (see the source-level inliner section below).
InlineImpossible also blocks functions with //go:uintptrescapes and
//go:uintptrkeepalive (because inlining loses the keep-alive
information), //go:yeswritebarrierrec, and functions with no body at all.
The second mechanism is the doNode switch, which rejects:
go,defer, tail calls —"unhandled op ...".- Closures, unless the debug flag
InlFuncsWithClosuresallows them:
// src/cmd/compile/internal/inline/inl.go:689
case ir.OCLOSURE:
if base.Debug.InlFuncsWithClosures == 0 {
v.reason = "not inlining functions with closures"
return true
}
// TODO(danscales): Maybe make budget proportional to number of closure
// variables, e.g.:
//v.budget -= int32(len(n.(*ir.ClosureExpr).Func.ClosureVars) * 3)
// TODO(austin): However, if we're able to inline this closure into
// v.curFunc, then we actually pay nothing for the closure captures. We
// should try to account for that if we're going to account for captures.
v.budget -= 15A closure costs 15 nodes. That’s not a rejection, just a chunk of your 80.
- Calls to
runtime.GetCallerPCandruntime.GetCallerSP, because inlining them would change what the program sees:
// src/cmd/compile/internal/inline/inl.go:522
case "internal/runtime/sys":
switch fn {
case "GetCallerPC", "GetCallerSP":
// Functions that call GetCallerPC/SP can not be inlined
// because users expect the PC/SP of the logical caller,
// but GetCallerPC/SP returns the physical caller.
v.reason = "call to " + fn
return true
}deferrangefunc, the helper the compiler synthesises fordeferinside a range-over-func.
Note that recover() is allowed to be inlined, but at the full
non-leaf call cost of 57:
// src/cmd/compile/internal/inline/inl.go:684
case ir.ORECOVER:
// recover matches panics to frames via stack unwinding
// (including inlined frames), so it is safe to inline.
v.budget -= v.extraCallCostSo a function containing recover() has 23 nodes of budget left. That is
usually enough for one or two simple statements and nothing more.
Big functions get a smaller budget
If the caller is big, the budget for the callee drops from 80 to 20:
// src/cmd/compile/internal/inline/inl.go:842
// IsBigFunc reports whether fn is a "big" function.
//
// Note: The criteria for "big" is heuristic and subject to change.
func IsBigFunc(fn *ir.Func) bool {
budget := inlineBigFunctionNodes
return ir.Any(fn, func(n ir.Node) bool {“Big” means more than 5000 IR nodes. The reasoning is straightforward: a 5000-node function is already generating a lot of machine code, and inlining 80 more nodes of callee into it makes the code worse, not better. This exists because issue #26546 and #17566 both asked for it.
PGO: the budget goes to 2000
Now the interesting part. If you build with a profile, the compiler knows which call sites are hot, and hot call sites get a much larger budget.
First, the profile is turned into a set of “hot” call sites. The threshold is not a flat percentage; it is based on the cumulative distribution function of edge weights:
// src/cmd/compile/internal/inline/inl.go:75
// Threshold in percentage for hot callsite inlining.
inlineHotCallSiteThresholdPercent float64
// Threshold in CDF percentage for hot callsite inlining,
// that is, for a threshold of X the hottest callsites that
// make up the top X% of total edge weight will be
// considered hot for inlining candidates.
inlineCDFHotCallSiteThresholdPercent = float64(99)
// Budget increased due to hotness.
inlineHotMaxBudget int32 = 200099% of total edge weight. So the hot set is “the smallest set of call sites that accounts for 99% of the profile’s total weight”. That is a much better rule than “top 100 call sites”, because a profile is usually dominated by a handful of genuinely hot edges.
The CDF is computed here:
// src/cmd/compile/internal/inline/inl.go:141
// hotNodesFromCDF computes an edge weight threshold and the list of hot
// nodes that make up the given percentage of the CDF. The threshold, as
// a percent, is the lower bound of weight for nodes to be considered hot
// (currently only used in debug prints) (in case of equal weights,
// comparing with the threshold may not accurately reflect which nodes are
// considered hot).
func hotNodesFromCDF(p *pgoir.Profile) (float64, []pgo.NamedCallEdge) {
cum := int64(0)
for i, n := range p.NamedEdgeMap.ByWeight {
w := p.NamedEdgeMap.Weight[n]
cum += w
if pgo.WeightInPercentage(cum, p.TotalWeight) > inlineCDFHotCallSiteThresholdPercent {
// nodes[:i+1] to include the very last node that makes it to go over the threshold.
// (Say, if the CDF threshold is 50% and one hot node takes 60% of weight, we want to
// include that node instead of excluding it.)
return pgo.WeightInPercentage(w, p.TotalWeight), p.NamedEdgeMap.ByWeight[:i+1]
}
}
return 0, p.NamedEdgeMap.ByWeight
}Then the budget function, which is where 80 becomes 2000:
// src/cmd/compile/internal/inline/inl.go:201
// inlineBudget determines the max budget for function 'fn' prior to
// analyzing the hairiness of the body of 'fn'. We pass in the pgo
// profile if available (which can change the budget), also a
// 'relaxed' flag, which expands the budget slightly to allow for the
// possibility that a call to the function might have its score
// adjusted downwards. If 'verbose' is set, then print a remark where
// we boost the budget due to PGO.
// Note that inlineCostOK has the final say on whether an inline will
// happen; changes here merely make inlines possible.
func inlineBudget(fn *ir.Func, profile *pgoir.Profile, relaxed bool, verbose bool) int32 {
// Update the budget for profile-guided inlining.
budget := int32(inlineMaxBudget)
budget *= simdCreditMultiplier(fn)
if strings.HasPrefix(ir.FuncName(fn), "runtime_mapaccess2") &&
fn.Sym().Pkg.Path == "internal/runtime/maps" {
// Increase budget for mapaccess2* functions so they could be
// inlined to mapaccess1* wrappers
budget = inlineHotMaxBudget
if verbose {
fmt.Printf("mapaccess enabled increased budget=%v for func=%v\n", budget, ir.PkgFuncName(fn))
}
}
if IsPgoHotFunc(fn, profile) {
budget = inlineHotMaxBudget
if verbose {
fmt.Printf("hot-node enabled increased budget=%v for func=%v\n", budget, ir.PkgFuncName(fn))
}
}
if relaxed {
budget += inlheur.BudgetExpansion(inlineMaxBudget)
}
if fn.ClosureParent != nil {
// be very liberal here, if the closure is only called once, the budget is large
budget = max(budget, inlineClosureCalledOnceCost)
}
return budget
}Three multipliers on top of 80:
- SIMD: 3x if the function takes or returns a SIMD vector type, 16x if it captures one. SIMD code is long and the payoff for inlining is large.
- Hot: 2000 if the function is a hot callee in the profile.
- Closure called once: 800 (10 × 80) if this is a closure.
And crucially, this budget is only about whether a function is eligible. The final decision is per-call-site, and it is stricter:
// src/cmd/compile/internal/inline/inl.go:978
// inlineCostOK returns true if call n from caller to callee is cheap enough to
// inline. bigCaller indicates that caller is a big function.
//
// In addition to the "cost OK" boolean, it also returns
// - the "max cost" limit used to make the decision (which may differ depending on func size)
// - the score assigned to this specific callsite
// - whether the inlined function is "hot" according to PGO.
func inlineCostOK(n *ir.CallExpr, caller, callee *ir.Func, bigCaller, closureCalledOnce bool) (bool, int32, int32, bool) {
maxCost := int32(inlineMaxBudget)and then the hot path:
// src/cmd/compile/internal/inline/inl.go:1023
if metric <= maxCost {
// Simple case. Function is already cheap enough.
return true, 0, metric, hot
}
// We'll also allow inlining of hot functions below inlineHotMaxBudget,
// but only in small functions.
if !hot {
// Cold
return false, maxCost, metric, false
}
// Hot
if bigCaller {
if base.Debug.PGODebug > 0 {
fmt.Printf("hot-big check disallows inlining for call %s (cost %d) at %v in big function %s\n", ir.PkgFuncName(callee), callee.Inl.Cost, ir.Line(n), ir.PkgFuncName(caller))
}
return false, maxCost, metric, false
}
if metric > inlineHotMaxBudget {
return false, inlineHotMaxBudget, metric, false
}So a hot call site in a small function can be inlined at up to cost 2000. A hot call site in a big function cannot, no matter how hot.
The new inliner: score adjustments
The GOEXPERIMENT=newinliner build changes the cost model from “count
nodes” to “count nodes, then adjust the score based on the call site’s
context”. It lives in src/cmd/compile/internal/inline/inlheur/.
The idea: the same function should be inlined at one call site and not another. A call in a loop benefits from inlining (the call overhead is paid every iteration). A call on a panic path does not (you are about to die anyway). A call whose argument is a constant benefits (the compiler can then fold a branch). A call passing a concrete type to an interface parameter benefits (the interface call can be devirtualised).
The adjustment table is a list of named bits:
// src/cmd/compile/internal/inline/inlheur/scoring.go:46
const (
// Category 1 adjustments (see above)
panicPathAdj scoreAdjustTyp = (1 << iota)
initFuncAdj
inLoopAdj
// Category 2 adjustments (see above).
passConstToIfAdj
passConstToNestedIfAdj
passConcreteToItfCallAdj
passConcreteToNestedItfCallAdj
passFuncToIndCallAdj
passFuncToNestedIndCallAdj
passInlinableFuncToIndCallAdj
passInlinableFuncToNestedIndCallAdj
// Category 3 adjustments.
returnFeedsConstToIfAdj
returnFeedsFuncToIndCallAdj
returnFeedsInlinableFuncToIndCallAdj
returnFeedsConcreteToInterfaceCallAdj
sentinelScoreAdj // sentinel; not a real adjustment
)and the values, with the compiler’s own admission about them:
// src/cmd/compile/internal/inline/inlheur/scoring.go:71
// This table records the specific values we use to adjust call
// site scores in a given scenario.
// NOTE: these numbers are chosen very arbitrarily; ideally
// we will go through some sort of turning process to decide
// what value for each one produces the best performance.
var adjValues = map[scoreAdjustTyp]int{
panicPathAdj: 40,
initFuncAdj: 20,
inLoopAdj: -5,
passConstToIfAdj: -20,
passConstToNestedIfAdj: -15,
passConcreteToItfCallAdj: -30,
passConcreteToNestedItfCallAdj: -25,
passFuncToIndCallAdj: -25,
passFuncToNestedIndCallAdj: -20,
passInlinableFuncToIndCallAdj: -45,
passInlinableFuncToNestedIndCallAdj: -40,
returnFeedsConstToIfAdj: -15,
returnFeedsFuncToIndCallAdj: -25,
returnFeedsInlinableFuncToIndCallAdj: -40,
returnFeedsConcreteToInterfaceCallAdj: -25,
}Negative numbers mean “promote” (inline it anyway), positive means “demote”. A call in a loop gets -5. A call on a panic path gets +40, which effectively disqualifies it.
The scoring itself starts from the raw cost and applies the adjustments:
// src/cmd/compile/internal/inline/inlheur/scoring.go:193
func (cs *CallSite) computeCallSiteScore(csa *callSiteAnalyzer, calleeProps *FuncProps) {
callee := cs.Callee
csflags := cs.Flags
call := cs.Call
// Start with the size-based score for the callee.
score := int(callee.Inl.Cost)
var tmask scoreAdjustTypThere is a subtlety with the budget: the new inliner does not know at
CanInline time whether a score will later be adjusted downwards, so it
starts with a relaxed budget:
// src/cmd/compile/internal/inline/inlheur/scoring.go:582
// BudgetExpansion returns the amount to relax/expand the base
// inlining budget when the new inliner is turned on; the inliner
// will add the returned value to the hairiness budget.
//
// Background: with the new inliner, the score for a given callsite
// can be adjusted down by some amount due to heuristics, however we
// won't know whether this is going to happen until much later after
// the CanInline call. This function returns the amount to relax the
// budget initially (to allow for a large score adjustment); later on
// in RevisitInlinability we'll look at each individual function to
// demote it if needed.
func BudgetExpansion(maxBudget int32) int32 {
if base.Debug.InlBudgetSlack != 0 {
return int32(base.Debug.InlBudgetSlack)
}
// In the default case, return maxBudget, which will effectively
// double the budget from 80 to 160; this should be good enough
// for most cases.
return maxBudget
}80 becomes 160 under the new inliner, so that a function whose score will be knocked down by -45 still fits.
The experiment flag itself is:
// src/internal/goexperiment/flags.go:99
// NewInliner enables a new+improved version of the function
// inlining phase within the Go compiler.
NewInliner booland the inlheur package is enabled exactly when that experiment is on (or when the debug dumping flags are set):
// src/cmd/compile/internal/inline/inlheur/analyze.go:217
func Enabled() bool {
return buildcfg.Experiment.NewInliner || UnitTesting()
}You can see the whole scoring model in action with a debug dump:
// src/cmd/compile/internal/inline/inlheur/scoring.go:605
// DumpInlCallSiteScores is invoked by the inliner if the debug flag
// "-d=dumpinlcallsitescores" is set; it dumps out a human-readable
// summary of all (potentially) inlinable callsites in the package,
// along with info on call site scoring and the adjustments made to a
// given score. Here profile is the PGO profile in use (may be
// nil), budgetCallback is a callback that can be invoked to find out
// the original pre-adjustment hairiness limit for the function, and
// inlineHotMaxBudget is the constant of the same name used in the
// inliner. Sample output lines:
//
// Score Adjustment Status Callee CallerPos ScoreFlags
// 115 40 DEMOTED cmd/compile/internal/abi.(*ABIParamAssignment).Offset expand_calls.go:1679:14|6 panicPathAdj
// 76 -5n PROMOTED runtime.persistentalloc mcheckmark.go:48:45|3 inLoopAdj
// 201 0 --- PGO unicode.DecodeRuneInString utf8.go:312:30|1
// 7 -5 --- PGO internal/abi.Name.DataChecked type.go:625:22|0 inLoopAdj“PROMOTED” and “DEMOTED” are the two outcomes. A promoted site was above the budget on raw cost but the adjustment brought it under.
The source-level inliner: //go:fix inline
There is a second, completely separate inliner in the Go tree, and it is
not part of cmd/compile at all. It lives in the vendored x/tools and
is driven by the go fix tool.
The directive is //go:fix inline, and it marks a function as a candidate
for source-level inlining — the compiler rewrites your source, not your
machine code:
// src/go/types/signature.go:57
//go:fix inline
func NewSignature(recv *Var, params, results *Tuple, variadic bool) *Signature {
return NewSignatureType(recv, nil, nil, params, results, variadic)
}The machinery that finds these directives is gofixdirective:
// src/cmd/vendor/golang.org/x/tools/go/analysis/passes/internal/gofixdirective/gofixdirective.go:1
// Package gofixdirective searches for and validates go:fix directives. The
// go/analysis/passes/inline package uses findgofix to perform inlining.
// The go/analysis/passes/gofix package uses findgofix to check for problems
// with go:fix directives.
//
// gofixdirective is separate from gofix to avoid depending on refactor/inline,
// which is large.
package gofixdirectiveAnd the analyzer that performs the inlining:
// src/cmd/vendor/golang.org/x/tools/go/analysis/passes/inline/inline.go:36
var Analyzer = &analysis.Analyzer{
Name: "inline",
Doc: analyzerutil.MustExtractDoc(doc, "inline"),
URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/inline",
Run: run,
FactTypes: []analysis.Fact{
(*goFixInlineFuncFact)(nil),
(*goFixInlineConstFact)(nil),
(*goFixInlineAliasFact)(nil),
},
Requires: []*analysis.Analyzer{
inspect.Analyzer,
typeindexanalyzer.Analyzer,
},
}This is a genuinely different design. cmd/compile’s inliner is a
cost-budget decision made per call site during compilation, invisible in
the source. The go fix inliner is a source transformation that bakes
the inlined body into your file permanently, so it can inline across
package boundaries and across type-parameter instantiations — things the
compiler’s inliner cannot do.
The modernize analyzer adds these directives automatically when it finds
a wrapper function that could be inlined:
// src/cmd/vendor/golang.org/x/tools/go/analysis/passes/modernize/newexpr.go:91
// Add a //go:fix inline annotation, if not already present.
//
// The inliner will not inline a newer callee body into an
// older Go file; see https://go.dev/issue/75726.
if !slices.ContainsFunc(astutil.Directives(decl.Doc), func(d ast.Directive) bool {
return d.Tool == "go" && d.Name == "fix" && d.Args == "inline"
}) {
edits = append(edits, analysis.TextEdit{
Pos: decl.Pos(),
End: decl.Pos(),
NewText: []byte("//go:fix inline\n"),
})
}Note the constraint in that comment: the inliner will not inline a newer callee body into an older Go file. The source-level inliner respects the language version of the file it is rewriting.
Reading the diagnostics
go build -gcflags=-m prints the decisions:
./main.go:10:6: can inline f with cost 34 as: func(i int) int { ... }
./main.go:20:6: cannot inline g: function too complex: cost 102 exceeds budget 80
./main.go:30:6: inlining call to fThe cost number is the number you are trying to reduce. A few practical notes:
- The cost is per function, not per call site (except under PGO and the new inliner).
- A cost just over 80 (say 85) usually means one non-inlinable call (57) plus a bit of body. Refactoring that call out usually fixes it.
- Cost under 80 does not guarantee inlining. The call site must also pass
the recursion check and the other
canInlineCallExprconditions. -m=2gives more detail, including the “cannot inline” reason.
Trade-offs
What inlining buys you: the ability to constant-fold, to propagate constants into the callee, to eliminate dead code, to devirtualise an interface call that becomes concrete after inlining the callee, and to avoid the call overhead entirely.
What it costs you: code size, and compile time. Inlining a 60-node function into 20 call sites produces 20 copies of that code. The budget exists to bound exactly this.
Why the budget is a count of IR nodes and not a count of instructions:
IR nodes are what the front end produced, which is a stable quantity the
compiler can compute without running the backend. It is a proxy, and the
refund cases (OAS2, OADDR at offset 0) exist because the proxy drifts
from reality.
Why hotness helps so much: the cost of inlining is paid at compile time and in binary size, but the benefit is paid at runtime, only on the paths that actually execute. A profile tells you which call sites are on the hot path, so you can spend code size where it pays.
Why PGO inlining is restricted to small callers: the point of PGO inlining is to remove overhead from a hot loop. A 5000-node function is not a hot loop; it is a big chunk of code where an extra copy of a callee is pure bloat.
Caveats
The budget is not a guarantee. A function with cost 40 might still not be inlined if the call site is inside a recursive cycle, or if the callee is a closure whose captured variables come from another closure frame.
//go:noinline is a blunt instrument. It blocks inlining of that
function entirely, everywhere, including in the runtime and in the
standard library’s hot paths. Use it when the function is genuinely large
or when inlining it would change semantics (for example, a function that
must keep its own stack frame for runtime.Callers).
The m output is not stable. The Go project explicitly says that
-m beyond a single -m “may go away at any point”. Do not write tests
that depend on the exact wording of the “cannot inline” message. The
project does, however, maintain a list of functions that are intended to
be inlinable, and TestIntendedInlining in
src/cmd/compile/internal/test/inl_test.go checks that list by parsing
the -m output.
The 128 KiB MaxStackVarSize and the inliner’s budget are unrelated.
Inlining a function that allocates a big local makes the caller’s frame
bigger, and there is a separate limit on frame size. The inliner does not
check it; the backend does.
The new inliner’s numbers are explicitly arbitrary. The comment in
adjValues says so. They are benchmark-derived but not principled, and
they will change.
PGO inlining only applies to the top 99% of edge weight. If your profile is noisy, or your program has many equally-hot call sites, the hot set can be large and the code-size benefit shrinks.
Wrapping up
The one thing to remember: the inliner charges one unit per IR node against a budget of 80, and a call to a function it cannot inline costs 57 of those units. If your function is “too complex”, the culprit is almost always one non-inlinable call.
The part people get wrong: inlining is decided per function in the default build, so making a function cheaper to inline helps every call site, but it also means a function that is hot at one call site and cold at fifty others is judged entirely on its own cost. That is what PGO and the new inliner’s score adjustments exist to fix.
What to go do about it: run go build -gcflags=-m and look at the cost
of the functions you expected to be inlined. If a cost is just over 80,
find the call inside it that is not itself inlinable and break that
dependency. If the function is genuinely hot, build with -pgo=./default.pgo
and let the profile lift the ceiling from 80 to 2000.
Further reading
src/cmd/compile/internal/inline/inl.go— the budget constants,InlineImpossible,hairyVisitor.doNode,inlineBudget, andinlineCostOK.src/cmd/compile/internal/inline/inlheur/scoring.go— the new inliner’s score adjustment table andBudgetExpansion.src/cmd/compile/internal/inline/inlheur/analyze.go— the function property analyzers, andEnabled().src/cmd/compile/internal/inline/inlheur/callsite.go— theCallSitestruct and the call-site property bits.src/cmd/compile/internal/inline/interleaved/interleaved.go— the pass that interleaves devirtualization and inlining.src/cmd/compile/internal/base/flag.go— the-land-mflags.src/cmd/compile/internal/ir/node.go— theNoinlinepragma flag.src/cmd/compile/internal/noder/lex.go— where//go:noinlineand friends are turned into pragma flags.src/internal/goexperiment/flags.go— theNewInlinerexperiment flag.src/cmd/compile/internal/pgoir/irgraph.go— the profile-weighted call graph the inliner consults.src/cmd/compile/internal/test/inl_test.go—TestIntendedInlining, which asserts the list of functions meant to be inlinable.src/cmd/vendor/golang.org/x/tools/go/analysis/passes/inline/inline.go— the source-level//go:fix inlineinliner.src/cmd/vendor/golang.org/x/tools/go/analysis/passes/internal/gofixdirective/gofixdirective.go— discovery and validation ofgo:fixdirectives.