blob: f0351e8f67144140b99a9e0c46887c857b9e153a [file] [log] [blame]
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package escape
import (
"cmd/compile/internal/base"
"cmd/compile/internal/ir"
"cmd/internal/src"
"fmt"
"maps"
"path/filepath"
)
type aliasAnalysis struct {
// fn is the function being analyzed.
fn *ir.Func
// candidateSlices are declared slices that
// start unaliased and might still be unaliased.
candidateSlices map[*ir.Name]candidateSlice
// noAliasAppends are appends that have been
// proven to use an unaliased slice.
noAliasAppends []*ir.CallExpr
// loops is a stack of observed loops,
// each with a list of candidate appends.
loops [][]candidateAppend
// State for optional validation checking (doubleCheck mode):
processed map[ir.Node]int // count of times each node was processed, for doubleCheck mode
doubleCheck bool // whether to do doubleCheck mode
}
// candidateSlice tracks information about a declared slice
// that might be unaliased.
type candidateSlice struct {
loopDepth int // depth of loop when slice was declared
}
// candidateAppend tracks information about an OAPPEND that
// might be using an unaliased slice.
type candidateAppend struct {
s *ir.Name // the slice argument in 's = append(s, ...)'
call *ir.CallExpr // the append call
}
// aliasAnalysis looks for specific patterns of slice usage and proves
// that certain appends are operating on non-aliased slices.
//
// This allows us to emit calls to free the backing arrays for certain
// non-aliased slices at runtime when we know the memory is logically dead.
//
// The analysis is conservative, giving up on any operation we do not
// explicitly understand.
func (aa *aliasAnalysis) analyze(fn *ir.Func) {
// Walk the function body to discover slice declarations, their uses,
// and any append that we can prove is using an unaliased slice.
//
// An example is:
//
// var s []T
// for _, v := range input {
// f()
// s = append(s, g(v)) // s cannot be aliased here
// h()
// }
// return s
//
// Here, we can prove that the append to s is operating on an unaliased slice,
// and that conclusion is unaffected by s later being returned and escaping.
//
// In contrast, in this example, the aliasing of s in the loop body means the
// append can be operating on an aliased slice, so we do not record s as unaliased:
//
// var s []T
// var alias []T
// for _, v := range input {
// s = append(s, v) // s is aliased on second pass through loop body
// alias = s
// }
//
// Arbitrary uses of s after an append do not affect the aliasing conclusion
// for that append, but only if the append cannot be revisited at execution time
// via a loop or goto.
//
// We track the loop depth when a slice was declared and verify all uses of a slice
// are non-aliasing until we return to that depth. In other words, we make sure
// we have processed any possible execution-time revisiting of the slice prior
// to making our final determination.
//
// This approach helps for example with nested loops, such as:
//
// var s []int
// for range 10 {
// for range 10 {
// s = append(s, 0) // s is proven as non-aliased here
// }
// }
// alias = s // both loops are complete
//
// Or in contrast:
//
// var s []int
// for range 10 {
// for range 10 {
// s = append(s, 0) // s is treated as aliased here
// }
// alias = s // aliased, and outermost loop cycles back
// }
//
// As we walk the function, we look for things like:
//
// 1. Slice declarations (currently supporting 'var s []T', 's := make([]T, ...)',
// and 's := []T{...}').
// 2. Appends to a slice of the form 's = append(s, ...)'.
// 3. Other uses of the slice, which we treat as potential aliasing outside
// of a few known safe cases.
// 4. A start of a loop, which we track in a stack so that
// any uses of a slice within a loop body are treated as potential
// aliasing, including statements in the loop body after an append.
// Candidate appends are stored in the loop stack at the loop depth of their
// corresponding slice declaration (rather than the loop depth of the append),
// which essentially postpones a decision about the candidate append.
// 5. An end of a loop, which pops the loop stack and allows us to
// conclusively treat candidate appends from the loop body based
// on the loop depth of the slice declaration.
//
// Note that as we pop a candidate append at the end of a loop, we know
// its corresponding slice was unaliased throughout the loop being popped
// if the slice is still in the candidate slice map (without having been
// removed for potential aliasing), and we know we can make a final decision
// about a candidate append if we have returned to the loop depth
// where its slice was declared. In other words, there is no unanalyzed
// control flow that could take us back at execution-time to the
// candidate append in the now analyzed loop. This helps for example
// with nested loops, such as in our examples just above.
//
// We give up on a particular candidate slice if we see any use of it
// that we don't explicitly understand, and we give up on all of
// our candidate slices if we see any goto or label, which could be
// unstructured control flow. (TODO(thepudds): we remove the goto/label
// restriction in a subsequent CL.)
//
// Note that the intended use is to indicate that a slice is safe to pass
// to runtime.freegc, which currently requires that the passed pointer
// point to the base of its heap object.
//
// Therefore, we currently do not allow any re-slicing of the slice, though we could
// potentially allow s[0:x] or s[:x] or similar. (Slice expressions that alter
// the capacity might be possible to allow with freegc changes, though they are
// currently disallowed here like all slice expressions).
//
// TODO(thepudds): we could support the slice being used as non-escaping function call parameter
// but to do that, we need to verify any creation of specials via user code triggers an escape,
// or mail better runtime.freegc support for specials, or have a temporary compile-time solution
// for specials. (Currently, this analysis side-steps specials because any use of a slice
// that might cause a user-created special will cause it to be treated as aliased, and
// separately, runtime.freegc handles profiling-related specials).
// Initialize.
aa.fn = fn
aa.candidateSlices = make(map[*ir.Name]candidateSlice) // slices that might be unaliased
// doubleCheck controls whether we do a sanity check of our processing logic
// by counting each node visited in our main pass, and then comparing those counts
// against a simple walk at the end. The main intent is to help catch missing
// any nodes squirreled away in some spot we forgot to examine in our main pass.
aa.doubleCheck = base.Debug.EscapeAliasCheck > 0
aa.processed = make(map[ir.Node]int)
if base.Debug.EscapeAlias >= 2 {
aa.diag(fn.Pos(), fn, "====== starting func", "======")
}
ir.DoChildren(fn, aa.visit)
for _, call := range aa.noAliasAppends {
if base.Debug.EscapeAlias >= 1 {
base.WarnfAt(call.Pos(), "alias analysis: append using non-aliased slice: %v in func %v",
call, fn)
}
if base.Debug.FreeAppend > 0 {
call.AppendNoAlias = true
}
}
if aa.doubleCheck {
doubleCheckProcessed(fn, aa.processed)
}
}
func (aa *aliasAnalysis) visit(n ir.Node) bool {
if n == nil {
return false
}
if base.Debug.EscapeAlias >= 3 {
fmt.Printf("%-25s alias analysis: visiting node: %12s %-18T %v\n",
fmtPosShort(n.Pos())+":", n.Op().String(), n, n)
}
// As we visit nodes, we want to ensure we handle all children
// without missing any (through ignorance or future changes).
// We do this by counting nodes as we visit them or otherwise
// declare a node to be fully processed.
//
// In particular, we want to ensure we don't miss the use
// of a slice in some expression that might be an aliasing usage.
//
// When doubleCheck is enabled, we compare the counts
// accumulated in our analysis against counts from a trivial walk,
// failing if there is any mismatch.
//
// This call here counts that we have visited this node n
// via our main visit method. (In contrast, some nodes won't
// be visited by the main visit method, but instead will be
// manually marked via countProcessed when we believe we have fully
// dealt with the node).
aa.countProcessed(n)
switch n.Op() {
case ir.ODCL:
decl := n.(*ir.Decl)
if decl.X != nil && decl.X.Type().IsSlice() && decl.X.Class == ir.PAUTO {
s := decl.X
if _, ok := aa.candidateSlices[s]; ok {
base.FatalfAt(n.Pos(), "candidate slice already tracked as candidate: %v", s)
}
if base.Debug.EscapeAlias >= 2 {
aa.diag(n.Pos(), s, "adding candidate slice", "(loop depth: %d)", len(aa.loops))
}
aa.candidateSlices[s] = candidateSlice{loopDepth: len(aa.loops)}
}
// No children aside from the declared ONAME.
aa.countProcessed(decl.X)
return false
case ir.ONAME:
// We are seeing a name we have not already handled in another case,
// so remove any corresponding candidate slice.
if n.Type().IsSlice() {
name := n.(*ir.Name)
_, ok := aa.candidateSlices[name]
if ok {
delete(aa.candidateSlices, name)
if base.Debug.EscapeAlias >= 2 {
aa.diag(n.Pos(), name, "removing candidate slice", "")
}
}
}
// No children.
return false
case ir.OAS2:
n := n.(*ir.AssignListStmt)
aa.analyzeAssign(n, n.Lhs, n.Rhs)
return false
case ir.OAS:
assign := n.(*ir.AssignStmt)
aa.analyzeAssign(n, []ir.Node{assign.X}, []ir.Node{assign.Y})
return false
case ir.OFOR, ir.ORANGE:
aa.visitList(n.Init())
if n.Op() == ir.ORANGE {
// TODO(thepudds): previously we visited this range expression
// in the switch just below, after pushing the loop. This current placement
// is more correct, but generate a test or find an example in stdlib or similar
// where it matters. (Our current tests do not complain.)
aa.visit(n.(*ir.RangeStmt).X)
}
// Push a new loop.
aa.loops = append(aa.loops, nil)
// Process the loop.
switch n.Op() {
case ir.OFOR:
forstmt := n.(*ir.ForStmt)
aa.visit(forstmt.Cond)
aa.visitList(forstmt.Body)
aa.visit(forstmt.Post)
case ir.ORANGE:
rangestmt := n.(*ir.RangeStmt)
aa.visit(rangestmt.Key)
aa.visit(rangestmt.Value)
aa.visitList(rangestmt.Body)
default:
base.Fatalf("loop not OFOR or ORANGE: %v", n)
}
// Pop the loop.
var candidateAppends []candidateAppend
candidateAppends, aa.loops = aa.loops[len(aa.loops)-1], aa.loops[:len(aa.loops)-1]
for _, a := range candidateAppends {
// We are done with the loop, so we can validate any candidate appends
// that have not had their slice removed yet. We know a slice is unaliased
// throughout the loop if the slice is still in the candidate slice map.
if cs, ok := aa.candidateSlices[a.s]; ok {
if cs.loopDepth == len(aa.loops) {
// We've returned to the loop depth where the slice was declared and
// hence made it all the way through any loops that started after
// that declaration.
if base.Debug.EscapeAlias >= 2 {
aa.diag(n.Pos(), a.s, "proved non-aliased append",
"(completed loop, decl at depth: %d)", cs.loopDepth)
}
aa.noAliasAppends = append(aa.noAliasAppends, a.call)
} else if cs.loopDepth < len(aa.loops) {
if base.Debug.EscapeAlias >= 2 {
aa.diag(n.Pos(), a.s, "cannot prove non-aliased append",
"(completed loop, decl at depth: %d)", cs.loopDepth)
}
} else {
panic("impossible: candidate slice loopDepth > current loop depth")
}
}
}
return false
case ir.OLEN, ir.OCAP:
n := n.(*ir.UnaryExpr)
if n.X.Op() == ir.ONAME {
// This does not disqualify a candidate slice.
aa.visitList(n.Init())
aa.countProcessed(n.X)
} else {
ir.DoChildren(n, aa.visit)
}
return false
case ir.OCLOSURE:
// Give up on all our in-progress slices.
closure := n.(*ir.ClosureExpr)
if base.Debug.EscapeAlias >= 2 {
aa.diag(n.Pos(), closure.Func, "clearing all in-progress slices due to OCLOSURE",
"(was %d in-progress slices)", len(aa.candidateSlices))
}
clear(aa.candidateSlices)
return ir.DoChildren(n, aa.visit)
case ir.OLABEL, ir.OGOTO:
// Give up on all our in-progress slices.
if base.Debug.EscapeAlias >= 2 {
aa.diag(n.Pos(), n, "clearing all in-progress slices due to label or goto",
"(was %d in-progress slices)", len(aa.candidateSlices))
}
clear(aa.candidateSlices)
return false
default:
return ir.DoChildren(n, aa.visit)
}
}
func (aa *aliasAnalysis) visitList(nodes []ir.Node) {
for _, n := range nodes {
aa.visit(n)
}
}
// analyzeAssign evaluates the assignment dsts... = srcs...
//
// assign is an *ir.AssignStmt or *ir.AssignListStmt.
func (aa *aliasAnalysis) analyzeAssign(assign ir.Node, dsts, srcs []ir.Node) {
aa.visitList(assign.Init())
for i := range dsts {
dst := dsts[i]
src := srcs[i]
if dst.Op() != ir.ONAME || !dst.Type().IsSlice() {
// Nothing for us to do aside from visiting the remaining children.
aa.visit(dst)
aa.visit(src)
continue
}
// We have a slice being assigned to an ONAME.
// Check for simple zero value assignments to an ONAME, which we ignore.
if src == nil {
aa.countProcessed(dst)
continue
}
if base.Debug.EscapeAlias >= 4 {
srcfn := ""
if src.Op() == ir.ONAME {
srcfn = fmt.Sprintf("%v.", src.Name().Curfn)
}
aa.diag(assign.Pos(), assign, "visiting slice assignment", "%v.%v = %s%v (%s %T = %s %T)",
dst.Name().Curfn, dst, srcfn, src, dst.Op().String(), dst, src.Op().String(), src)
}
// Now check what we have on the RHS.
switch src.Op() {
// Cases:
// Check for s := make([]T, ...) or s := []T{...}, along with the '=' version
// of those which does not alias s as long as s is not used in the make.
//
// TODO(thepudds): we need to be sure that 's := []T{1,2,3}' does not end up backed by a
// global static. Ad-hoc testing indicates that example and similar seem to be
// stack allocated, but that was not exhaustive testing. We do have runtime.freegc
// able to throw if it finds a global static, but should test more.
//
// TODO(thepudds): could also possibly allow 's := append([]T(nil), ...)'
// and 's := append([]T{}, ...)'.
case ir.OMAKESLICE, ir.OSLICELIT:
name := dst.(*ir.Name)
if name.Class == ir.PAUTO {
if base.Debug.EscapeAlias > 1 {
aa.diag(assign.Pos(), assign, "assignment from make or slice literal", "")
}
// If this is Def=true, the ODCL in the init will causes this to be tracked
// as a candidate slice. We walk the init and RHS but avoid visiting the name
// in the LHS, which would remove the slice from the candidate list after it
// was just added.
aa.visit(src)
aa.countProcessed(name)
continue
}
// Check for s = append(s, <...>).
case ir.OAPPEND:
s := dst.(*ir.Name)
call := src.(*ir.CallExpr)
if call.Args[0] == s {
// Matches s = append(s, <...>).
// First visit other arguments in case they use s.
aa.visitList(call.Args[1:])
// Mark the call as processed, and s twice.
aa.countProcessed(s, call, s)
// We have now examined all non-ONAME children of assign.
// This is now the heart of the analysis.
// Check to see if this slice is a live candidate.
cs, ok := aa.candidateSlices[s]
if ok {
if cs.loopDepth == len(aa.loops) {
// No new loop has started after the declaration of s,
// so this is definitive.
if base.Debug.EscapeAlias >= 2 {
aa.diag(assign.Pos(), assign, "proved non-aliased append",
"(loop depth: %d, equals decl depth)", len(aa.loops))
}
aa.noAliasAppends = append(aa.noAliasAppends, call)
} else if cs.loopDepth < len(aa.loops) {
// A new loop has started since the declaration of s,
// so we can't validate this append yet, but
// remember it in case we can validate it later when
// all loops using s are done.
aa.loops[cs.loopDepth] = append(aa.loops[cs.loopDepth],
candidateAppend{s: s, call: call})
} else {
panic("impossible: candidate slice loopDepth > current loop depth")
}
}
continue
}
} // End of switch on src.Op().
// Reached bottom of the loop over assignments.
// If we get here, we need to visit the dst and src normally.
aa.visit(dst)
aa.visit(src)
}
}
func (aa *aliasAnalysis) countProcessed(nodes ...ir.Node) {
if aa.doubleCheck {
for _, n := range nodes {
aa.processed[n]++
}
}
}
func (aa *aliasAnalysis) diag(pos src.XPos, n ir.Node, what string, format string, args ...any) {
fmt.Printf("%-25s alias analysis: %-30s %-20s %s\n",
fmtPosShort(pos)+":",
what+":",
fmt.Sprintf("%v", n),
fmt.Sprintf(format, args...))
}
// doubleCheckProcessed does a sanity check for missed nodes in our visit.
func doubleCheckProcessed(fn *ir.Func, processed map[ir.Node]int) {
// Do a trivial walk while counting the nodes
// to compare against the counts in processed.
observed := make(map[ir.Node]int)
var walk func(n ir.Node) bool
walk = func(n ir.Node) bool {
observed[n]++
return ir.DoChildren(n, walk)
}
ir.DoChildren(fn, walk)
if !maps.Equal(processed, observed) {
// The most likely mistake might be something was missed while building processed,
// so print extra details in that direction.
for n, observedCount := range observed {
processedCount, ok := processed[n]
if processedCount != observedCount || !ok {
base.WarnfAt(n.Pos(),
"alias analysis: mismatch for %T: %v: processed %d times, observed %d times",
n, n, processedCount, observedCount)
}
}
base.FatalfAt(fn.Pos(), "alias analysis: mismatch in visited nodes")
}
}
func fmtPosShort(xpos src.XPos) string {
// TODO(thepudds): I think I did this a simpler way a while ago? Or maybe add base.FmtPosShort
// or similar? Or maybe just use base.FmtPos and give up on nicely aligned log messages?
pos := base.Ctxt.PosTable.Pos(xpos)
shortLine := filepath.Base(pos.AbsFilename()) + ":" + pos.LineNumber()
return shortLine
}