Skip to content

Proposal: LLGo LLVM-IR escape analysis and heap-to-stack transform #2244

Description

@MeteorsLiu

LLGo LLVM-IR escape analysis and heap-to-stack transform

Status: proposal

Summary

LLGo should implement its own escape-analysis and heap-to-stack transformation pass over the complete LLVM module produced by LLGo. The pass should be integrated at the same architectural boundary as internal/cabi: frontend lowering first emits the complete package module, then an LLGo-owned module transformer analyzes and rewrites that module before the generic LLVM optimization pipeline runs.

The proposed default pipeline is:

Go source
    -> go/ssa
    -> cl lowers the complete package to LLVM IR
    -> LLGo escape analysis and heap-to-stack rewrite
    -> LowerLargeAggregates and C ABI rewrite
    -> generic LLVM optimization passes

The escape analyzer operates on LLVM SSA def-use chains, not on Go AST or x/tools go/ssa. It starts from LLGo allocation calls and LLVM function parameters, follows their transitive uses, computes allocation escape decisions and parameter leak summaries, and then rewrites proven-local heap allocations into stack allocations.

The work has two phases:

  1. Implement a conservative LLVM-SSA use analysis and heap-to-stack rewrite. Unknown uses escape. This phase produces structured -m summaries but applies only generic pointer-flow rules.
  2. Add LLGo-specific Go semantic rules for channels, maps, interfaces, closures, slices, goroutines, defers, panic/recover, typed memory copies, C declarations, and pointer/uintptr contracts. These rules make the LLGo pass more precise than generic LLVM AAHeapToStack.

When detailed diagnostics are requested, the analysis may retain predecessor edges as an explanation graph. That graph exists only to explain the computed -m result; it is not the analysis solver.

Why LLGo needs its own pass

There are three independent reasons.

1. Go's escape analyzer is AST/Go-compiler-IR based and tightly coupled

The Go compiler's escape analyzer is not a reusable SSA pass. It walks cmd/compile IR, constructs a location graph, and depends on Go compiler concepts including expression nodes, assignment holes, loop depth, closures, pragmas, inlining state, result parameters, and export tags.

Porting it to LLGo would require recreating a large part of cmd/compile's frontend-specific environment and continuously tracking internal Go compiler changes. The analysis difficulty and coupling are disproportionate to LLGo's needs.

2. LLVM AAHeapToStack cannot produce the required -m summaries

AAHeapToStack answers a narrow optimization question: can a recognized allocation call be replaced by an alloca? A successful rewrite is evidence that one allocation is local. A failed rewrite is only unknown.

It does not expose the source-level information LLGo needs for -m, including:

  • p does not escape;
  • leaking param: p;
  • leaking param content: p;
  • p flowing to result N at dereference level K;
  • mutator and called-function effects needed for summary composition;
  • an explanation path tied to Go source positions.

These summaries must be computed and retained by LLGo itself.

3. LLVM AAHeapToStack cannot optimize according to Go semantics

Generic LLVM sees calls, pointers, aggregates, byte copies, and opaque runtime helpers. It does not know LLGo's language/runtime contracts. For example:

  • the pointer passed to runtime.ChanSend is a temporary transport address borrowed until the call returns, while pointer-containing bytes copied from that address may remain in channel storage;
  • a slice value contains a backing-store pointer whose behavior under append depends on capacity and possible reallocation;
  • an interface payload may be represented directly or copied into an indirect box according to LLGo's ABI classification;
  • a closure environment contains captured Go values and escapes according to the closure value's uses;
  • goroutine startup data must outlive the caller even when the callee does not retain an argument after startup;
  • typed memory moves preserve Go pointer fields in ways that an untyped byte-copy model cannot recover safely;
  • //go:noescape and uintptr keepalive are Go compiler contracts, not generic LLVM pointer attributes.

An LLGo-owned pass can model these semantics explicitly and conservatively.

Verified current pipeline

The current repository already has the required transformation boundary.

internal/build/build.go calls cl.NewPackageExWithEmbedMeta, which returns the generated package and its live llvm.Module. For a cache miss, the current order is:

cl.NewPackageExWithEmbedMeta
    -> ModuleHook observes the complete module
    -> llabi.LowerLargeAggregates
    -> cabi.Transformer.TransformModule
    -> LLVM VerifyModule
    -> Module.RunPasses(default<O*>)

internal/cabi/cabi.go demonstrates the intended implementation shape. TransformModule walks LLVM functions, basic blocks, and call instructions, gathers rewrite candidates, and then rewrites function signatures and call sites directly in the completed module.

The escape pass should use the same module-transform pattern. It should not be implemented inside individual cl lowering cases and should not require changes to xgo-dev/llvm merely to define a normal LLGo pass.

The pass should run before LowerLargeAggregates and C ABI rewriting by default. At that point the module is complete, but Go aggregate/function relationships have not yet been obscured by ABI signature changes. The implementation must verify this ordering with C ABI and large-aggregate tests; if a specific rewrite requires a later position, that dependency must be explicit rather than silently inferred from transformed IR.

Goals

  • Analyze the complete LLVM module emitted for one LLGo package.
  • Recognize LLGo heap-allocation sites and prove when their result is frame-local.
  • Produce parameter heap/result summaries with dereference levels for -m and direct-call composition.
  • Rewrite proven-local runtime.AllocZ and runtime.AllocU calls into equivalent stack allocations.
  • Preserve allocation size, alignment, initialization, lifetime, pointer identity, and target restrictions.
  • Treat every unsupported use conservatively.
  • Add Go semantic transfer rules independently from the generic pointer-flow engine.
  • Export the minimum summaries needed by separately compiled importing packages.
  • Keep analysis facts independent of the selected GC implementation.

Out of scope

  • Porting cmd/compile/internal/escape.
  • Using x/tools go/ssa as the final analysis IR.
  • Enabling all of LLVM Attributor or depending on unstable debug output from AAHeapToStack.
  • Parsing LLVM optimization remarks as a compiler interface.
  • Stack allocation of maps, channels, goroutine startup records, or other runtime-owned objects before their complete lifetime contracts are modeled.
  • A context-sensitive whole-program pointer analysis in Phase 1.
  • Byte-for-byte compatibility with every Go -m message in the first implementation.
  • Choosing a persistent summary wire layout before the in-memory summary and its consumers are validated.

Module boundary and pass position

Input

The pass consumes the complete LLVM package module immediately after cl generation. It sees:

  • all LLVM functions defined in the package;
  • direct calls to package functions, imported declarations, runtime helpers, and C declarations;
  • LLVM parameters, return instructions, loads, stores, GEPs, casts, phi/select nodes, aggregate operations, intrinsics, and globals;
  • LLGo allocation calls generated by ssa/memory.go and other lowering helpers;
  • source/debug metadata already emitted by LLGo;
  • additional minimal LLGo semantic/source metadata required by this proposal.

The pass does not consume the original Go AST. It may receive immutable type/source facts recorded during lowering, but the analysis graph and rewrite operate on LLVM values and instructions.

Output

The pass returns:

  • a rewritten LLVM module;
  • per-allocation decisions and reasons;
  • per-function parameter summaries;
  • source-positioned structured diagnostics when requested;
  • package summary facts for later metadata transport.

The transformer should mutate the live module synchronously, as cabi.TransformModule does. Analysis data should be released after diagnostics and package metadata have consumed it.

Ordering

The initial order should be:

complete cl-generated module
    -> escape analysis fixed point
    -> heap-to-stack rewrite
    -> VerifyModule
    -> LowerLargeAggregates
    -> C ABI TransformModule
    -> existing LLVM optimization pipeline

Running before generic LLVM optimization preserves allocation/runtime helper identities and source associations. Later LLVM passes remain free to optimize the rewritten allocas.

Required lowering metadata

LLVM SSA contains pointer flow, but it does not retain every Go source concept required for summaries and Go-specific transfer rules. LLGo lowering should provide only the minimum facts that cannot be reconstructed reliably.

The pass needs stable associations for:

  • Go function identity and source position;
  • LLVM parameter to Go receiver/parameter index and source name;
  • LLVM result component to Go result index;
  • heap allocation call to Go source allocation position and display name;
  • pointer-carrying components of lowered Go aggregates when LLVM layout alone is insufficient;
  • semantic identity of compiler-generated operations when a runtime symbol name is not a stable contract.

The exact representation may be an in-memory side table, LLVM metadata, or a combination. It must survive until this pass and must not become part of the runtime ABI. A symbol-name heuristic is acceptable only for symbols that LLGo already treats as a compiler/runtime contract and covers with focused tests.

No speculative source metadata should be added. Each field must have a demonstrated analysis or diagnostic consumer.

Analysis basis

Analysis units and cached results

The analysis has two root kinds:

  • an eligible LLGo allocation call, whose result is local, returned, heap, or unknown;
  • a pointer-carrying formal parameter, whose NoCapture state is cached for heap-to-stack and whose flows may also be recorded in locationGraph for summaries.

Only formal-parameter NoCapture facts are used as the interprocedural optimization cache. The cache key identifies a defined LLVM function and one formal parameter. An allocation remains a caller-local root; when it crosses a direct call, the caller queries the cached callee-parameter NoCapture fact. Parameter leak summaries are derived separately from locationGraph and never feed an allocation decision.

The dependency direction is one-way: LLVM use traversal may write locationGraph for -m, but NoCapture, allocation decisions, and rewrite logic never read locationGraph or imported diagnostic summaries.

The pass creates allocation roots for direct calls to eligible LLGo allocation helpers and parameter roots for pointer-carrying Go receivers and parameters represented in the LLVM signature. Pointer-carrying closure components may become parameter roots when lowering metadata identifies them. runtime.AllocRoot is excluded because its scanned-root and cross-goroutine lifetime contract is not an ordinary heap allocation contract.

Per-root LLVM use traversal

The function-local walker should use the same shape as LLVM Attributor::checkForAllUses, which is used by AAHeapToStackFunction::UsesCheck. It starts with every LLVM use of the root value, processes a worklist of uses, and follows the users of derived values. When the tracked pointer is stored as a value, the walker applies the same AAPointerInfo-backed exact-copy query as LLVM getPotentialCopiesOfStoredValue(..., OnlyExact=true): proven load copies are added to the existing use worklist, while an incomplete proof makes the root escape. At a direct call, heap-to-stack queries the callee parameter's cached NoCapture fact. Phase 1 does not build a NoFree cache; bodyless, runtime, C, indirect, and unresolved calls remain capturing.

The heap-to-stack traversal state is:

use          exact LLVM Use, including its operand position

Each evaluation owns a fresh visited map keyed by this state. If insertion reports that the state was already visited, that path stops. Use rather than Instruction is required because the same instruction can use a root in different operand roles. Following a phi, select, GEP, cast, or loop-carried value eventually reaches the same use state and terminates without CFG traversal, loop decomposition, liveness analysis, or call-graph SCC construction.

Conceptually:

walk(root):
    worklist <- every use of root.value
    visited  <- empty map
    local    <- true

    while worklist is not empty:
        state <- pop worklist
        if state is already in visited:
            continue
        add state to visited
        if detailed diagnostics are requested:
            locationGraph.add(state)  // write-only; never queried by this walk

        switch exact user and operand role:
        case load address:
            stop this path
        case store address:
            stop this path; the address itself is not retained
        case store value:
            if every potential copy is proven exact:
                add every use of every copied load result to worklist
            else:
                local <- false
        case GEP, pointer-preserving cast, phi, or select:
            add every use of the derived result to worklist
        case return value:
            local <- false
        case direct call argument:
            local <- local && NoCapture[callee, parameter]
        case bodyless, runtime, C, indirect, or unresolved call argument:
            local <- false
        case verified LLGo semantic operation:
            apply its transfer rule
        default:
            local <- false

    return local

Exact-copy forwarding

User story and motivation

An unoptimized module may preserve local pointer assignments as memory operations:

define void @local_copy() {
entry:
  %object = call ptr @runtime.AllocU(i64 8)
  %p.addr = alloca ptr
  store ptr %object, ptr %p.addr
  %p = load ptr, ptr %p.addr
  %q.addr = alloca ptr
  store ptr %p, ptr %q.addr
  %q = load ptr, ptr %q.addr
  store i8 1, ptr %q
  ret void
}

The allocation does not outlive @local_copy. A plain def-use walker nevertheless stops at store ptr %object, ptr %p.addr, because the allocation pointer has crossed into memory. Treating that store as an immediate escape would make HeapToStack depend on an earlier mem2reg-style optimization and would miss ordinary local assignments whenever their slots remain in the input IR.

Exact-copy forwarding proves that the first store can produce %p, that the second store can produce %q, and then resumes the existing use walk at %q.uses(). The final store i8 1, ptr %q writes through the allocation rather than publishing its address, so the allocation remains eligible for stack conversion.

The same mechanism must preserve the escaping case:

@sink = external global ptr

define void @escaping_copy() {
entry:
  %object = call ptr @runtime.AllocU(i64 8)
  %slot = alloca ptr
  store ptr %object, ptr %slot
  %copy = load ptr, ptr %slot
  store ptr %copy, ptr @sink
  ret void
}

The local slot query returns %copy, so the root-use walker reaches store ptr %copy, ptr @sink. Because an externally visible mutable global cannot provide a complete set of exact load copies, that second query fails and the store remains a capturing use. The allocation therefore stays on the heap.

The required rule is: crossing a store/load boundary is safe only when the analysis returns the complete may-set of exact load copies and the ordinary use walker proves every returned copy local. An empty complete set is safe; an incomplete set is an escape.

Exact-copy forwarding is the only Phase 1 rule that crosses a store/load memory boundary. Its implementation follows LLVM 19.1.7 Attributor::checkForAllUses and AA::getPotentialCopiesOfStoredValue(..., OnlyExact=true). The responsibilities remain separate:

  1. pointerInfo reproduces the required AAPointerInfo behavior for an underlying memory object. It follows address-preserving uses and records accesses relative to that object.
  2. getPotentialCopiesOfStoredValue queries those access records for one store and either returns the complete conservative set of possible load copies or fails without returning a partial set.
  3. The shared checkForAllUses engine consumes the returned copies. In the allocation walk it follows copied allocation values; while constructing pointerInfo, it follows copied addresses and preserves their offset state. The per-analysis predicate still owns instruction classification.

The generic store-copy interception runs before the caller's predicate. A successful query substitutes uses of the returned loads for the original store-value use. A failed query falls through to the predicate, where storing the tracked value invalidates HeapToStack or pointerInfo as appropriate.

pointerInfo starts the associated object at offset zero. GEPs add target-layout byte offsets. Pointer-preserving casts retain offsets. Selects and returns pass offsets through. Phis merge offsets and apply LLVM's explicit recurrence rule: an invariant recurrence keeps its offsets, while a non-invariant recurrence becomes unknown. Constant-expression casts and GEPs follow the same rules. Loads record reads. Stores through the tracked address record writes and their content lattice value. Atomic RMW and cmpxchg record their LLVM read/write roles but invalidate the state when the tracked address is used as a published value operand. Calls translate the callee or call-site-argument pointerInfo; memory intrinsics record their source and destination ranges. Any use not handled by LLVM's AAPointerInfoFloating::updateImpl invalidates the state rather than receiving an LLGo-specific rule.

Each access contains the local instruction, the actual remote memory instruction for an imported callee access, all possible {offset, size} ranges, read/write and may/must bits, content in LLVM's undetermined / unknown / value lattice, and the accessed type. Sizes come from the module data layout. Multiple possible ranges turn a must-access into a may-access. Unknown offsets or sizes overlap every range. Accesses with the same local and remote instruction are merged exactly as AAPointerInfo::State::addAccess merges them and are indexed by their ranges and remote instruction.

The generic walker consumes a stored-value query as follows:

checkForAllUses(root, predicate, equivalentUse):
    worklist <- every use of root

    while worklist is not empty:
        use <- pop worklist

        if use.user is Store and use is store.operand[0]:
            copies, complete <- getPotentialCopiesOfStoredValue(store, OnlyExact=true)
            if complete:
                for each copy in copies:
                    for each newUse in copy.uses:
                        require equivalentUse(oldUse=use, newUse=newUse)
                        append newUse to worklist
                continue  // the predicate does not see the original store

        follow <- false
        require predicate(use, &follow)
        if follow:
            append use.user.uses to worklist

The stored-value query is:

getPotentialCopiesOfStoredValue(store, OnlyExact=true):
    destination <- store.pointerOperand
    objects, complete <- underlyingObjects(destination)
    if not complete:
        return failure

    pendingCopies <- empty ordered set
    pendingInfos  <- empty list

    for each object in objects:
        if object is undef:
            continue

        if object is null:
            if null dereference is undefined in this function and
               destination simplifies exactly to null:
                continue
            return failure

        if object is not an alloca, supported global, or noalias call result:
            return failure

        info <- pointerInfo(object)
        if info is missing or invalid:
            return failure

        nullOnly     <- true
        nullRequired <- false

        checkReader(access, isExact):
            if access is not a read:
                return success

            update nullOnly and nullRequired from access.content

            if OnlyExact and not isExact and
               content is neither consistently null nor undef:
                return failure

            if nullRequired and a possible non-null or unknown content exists:
                return failure

            if OnlyExact and access.remoteInstruction is not Load:
                return failure

            pendingCopies.add(access.remoteInstruction)
            return success

        complete <- info.forAllInterferingAccesses(
            query                   = store,
            findInterferingWrites   = false,
            findInterferingReads    = true,
            check                  = checkReader,
            threadingAndReachabilityGate,
            exactMustWriteBlockers)
        if not complete:
            return failure

        pendingInfos.append(info)

    // Do not expose partial copies or dependencies before every object succeeds.
    record dependencies on pendingInfos
    return success with pendingCopies
Situation Result
Underlying objects cannot be enumerated completely Fail without returning partial copies
Underlying object is undef Ignore that object
Destination simplifies exactly to null and null dereference is undefined Ignore that object because the access is undefined
Null is a valid address, or the destination is an offset from null Fail
Object is an alloca Query its pointerInfo
Global has local linkage Query its pointerInfo
Global is constant and has an initializer Query its pointerInfo
Global is externally visible and mutable Fail
Object is a recognized noalias call result Query its pointerInfo
Any other underlying object Fail
pointerInfo is unavailable, incomplete, or invalid Fail
Access does not read the queried range Ignore it
Read range is disjoint from the store range Ignore it
Threading gate permits reachability reasoning and no unblocked path reaches the read Ignore it
An exact must-write overwrites the complete stored range before a read on every path Treat that write as a blocker; the old value does not reach that read
Exact overlapping ordinary load Add the load result to pendingCopies
Exact load uses a different LLVM value type but reads the same byte range Add the load result; exactness is range-based
Multiple loads may read the value Add every load result; the result is a complete may-set
No load may read the value Succeed with an empty copy set
Non-exact read is proven consistently null or undef Permit only LLVM's null/undef exception; the remote reader must still be a load
Non-exact read has unknown or possibly non-null content Fail
A previous non-exact read requires null-only content and another read may be non-null Fail
Interfering remote access is a direct-callee load translated to this call site Add the remote load result
Interfering remote access is a memory intrinsic, unresolved call, or any non-load instruction Fail when OnlyExact=true
Any underlying object or reader fails after earlier copies were collected Discard all pending copies and fail
Every object and reader succeeds Commit dependencies and return the deduplicated copy set
equivalentUse rejects a returned copy because offsets or thread visibility are incompatible checkForAllUses fails; the caller cannot treat the store as forwarded

locationGraph is diagnostics-only. It may record a flow from the original store value to the already-returned load results after this algorithm succeeds, but it cannot provide underlying objects, ranges, accesses, reachability, copies, or an optimization decision.

Parameter traversal updates the finite NoCapture map and may record diagnostic flows after exact-copy forwarding succeeds. Allocation traversal cannot conclude local until that map reaches its fixed point. Neither traversal reads locationGraph.

Direct calls and NoCapture cache

When a tracked value is direct argument I of a call to a function defined in the current LLVM module, the analyzer queries a boolean map keyed by (callee, formal parameter I). Pointer parameters of analyzable definitions begin optimistically as true. A capturing or unsupported use changes the value to false; values never change back to true.

initialize:
    NoCapture[defined pointer parameter] <- true

repeat:
    changed <- false
    for each true NoCapture entry:
        if any use captures the parameter under current callee facts:
            entry <- false
            changed <- true
until changed is false

This is a finite optimistic fixed point, matching the role AANoCapture plays for LLVM heap-to-stack without reproducing the complete Attributor framework. Mutual recursion with no capturing sink remains true; if any member finds a capturing sink, later iterations propagate false to callers. With N entries, at most N boolean state changes are possible. No call-graph SCC, dereference level, or recursive parameter-summary composition is involved.

Function declarations and unresolved callees have no analyzable entry and are capturing in Phase 1. Current LLGo C and runtime declarations do not receive nocapture automatically, so deallocation calls remain capturing without a separate NoFree analysis. No allocation is analyzed for rewrite until the boolean map reaches its fixed point.

Diagnostic summary recording

locationGraph may separately record the flows needed to derive Go diagnostics:

  • parameter flow to heap at level K records a heap leak;
  • parameter flow to result N at level K records a result summary;
  • mutator and callee flows are retained for diagnostic summaries;
  • an unrepresentable result mapping or dereference level becomes unknown.

These facts are diagnostic data only. Heap-to-stack never reads locationGraph, never composes these summaries into an allocation walk, and never changes its decision when diagnostics are disabled. A function declaration has no NoCapture entry, so pointer-carrying arguments escape; this already covers ordinary C and runtime declarations. An indirect call or unresolved interface dispatch is also unknown in Phase 1.

Examples

A callee that only reads its pointer parameter has an empty leak summary:

declare ptr @malloc(i64)

define void @read(ptr %p) {
entry:
  %v = load i32, ptr %p
  ret void
}

define void @caller() {
entry:
  %p = call ptr @malloc(i64 4)
  store i32 42, ptr %p
  call void @read(ptr %p)
  ret void
}

The NoCapture entry for (@read, parameter 0) remains true because its only use is a load address. Walking %p in caller accepts the call after the boolean map reaches its fixed point, so the allocation remains local.

An exact local store/load copy stays in the same use worklist:

define void @caller_with_slot() {
entry:
  %p = call ptr @malloc(i64 4)
  %slot = alloca ptr
  store ptr %p, ptr %slot
  %q = load ptr, ptr %slot
  call void @read(ptr %q)
  ret void
}

When traversal reaches store ptr %p, ptr %slot, the OnlyExact query proves that %q is the complete load-copy set. The walker adds %q.uses() to its existing worklist and reaches call @read. If %slot has a non-exact alias, an unsupported reader, an unknown clobber, or escapes to another function, the exact-copy query fails and %p escapes.

A callee that stores the pointer into a global produces a heap fact:

@escaped = global ptr null
declare ptr @malloc(i64)

define void @save(ptr %p) {
entry:
  store ptr %p, ptr @escaped
  ret void
}

define void @caller() {
entry:
  %p = call ptr @malloc(i64 4)
  call void @save(ptr %p)
  ret void
}

Evaluating (@save, parameter 0) changes its NoCapture entry to false. The caller therefore rejects the call use and does not rewrite the allocation. locationGraph may separately retain the store-to-global path for diagnostics.

A result fact is diagnostic only for Phase 1 heap-to-stack:

declare ptr @malloc(i64)

define ptr @identity(ptr %p) {
entry:
  ret ptr %p
}

define i32 @caller() {
entry:
  %p = call ptr @malloc(i64 4)
  store i32 42, ptr %p
  %q = call ptr @identity(ptr %p)
  %v = load i32, ptr %q
  ret i32 %v
}

Returning %p makes NoCapture[identity, 0] false, so Phase 1 heap-to-stack does not rewrite this caller allocation. locationGraph records the parameter-to-result flow for diagnostics, but that result summary does not participate in optimization.

Recursion uses the finite NoCapture map, not summary composition or the uses visited map:

@escaped = global ptr null

define void @recursive(ptr %p) {
entry:
  call void @recursive(ptr %p)
  store ptr %p, ptr @escaped
  ret void
}

The entry starts as true. The global store changes it to false; the next map iteration observes false at the recursive call and reaches the same fixed point. No dereference-indexed recursive fact is created.

Dynamic calls

An indirect function call or unresolved interface dispatch is unknown in Phase 1:

  • pointer-carrying arguments escape;
  • a function-valued parameter records a callee effect;
  • pointer-carrying results have unknown provenance;
  • closure context reachable from an unresolved callee escapes.

A later sound possible-target analysis may join several summaries. Target discovery is separate from escape analysis and must remain conservative under reflection, C callbacks, and function pointers.

Imported summary transport

Per-package compilation requires caller-visible summaries to cross package boundaries. The minimum facts are function identity, Go parameter index, sink kind, result index when applicable, dereference level, and summary version.

internal/meta already owns LLGo package metadata and cache transport. The concrete encoding should be added only after the in-memory summary is tested. Missing, stale, or incompatible data must use the unknown-call rule.

In Phase 1, imported summaries are diagnostic transport only. They never create a NoCapture entry and are never read by allocation traversal or rewrite.

Phase 1 LLVM instruction rules

Every LLVM instruction that can use a pointer root must have an explicit operand-role rule. An unclassified instruction is an analyzer implementation error in tests and an unknown escape in production until classified.

Transparent pointer derivations

LLVM use Phase 1 rule
getelementptr base Follow the result with an appended bounded component path.
bitcast or pointer-preserving addrspacecast Follow the result. Unsupported address-space lifetime changes are unknown.
phi incoming value Follow the phi result. Visited state terminates cycles.
select pointer arm Follow the select result.
freeze pointer operand Follow the result.
extractvalue aggregate operand Follow the selected component if metadata/layout maps it to the root. Otherwise unknown.
insertvalue inserted operand Follow the resulting aggregate component.
insertvalue base aggregate Follow preserved components; overwrite of the tracked component stops that path.
extractelement/insertelement/shufflevector with pointer elements Track a constant bounded lane; dynamic or merged lanes become unknown.

Loads, stores, and atomics

LLVM use Phase 1 rule
pointer used as load address Safe for an allocation root. For a Go parameter root, a pointer-valued load may be followed at dereference level +1.
pointer stored as store value A global, TLS, non-local, or unknown destination is a heap sink. Otherwise run the OnlyExact potential-copy query: follow every proven load result, and use heap/unknown if the proof is incomplete.
pointer used as store address Safe for allocation identity; record mutator effect for a parameter root.
atomic load address Same lifetime rule as load; ordering does not retain the address.
atomic store value Apply the same destination-lifetime rule. A local copy is followed only when the exact-copy proof covers the atomic and thread-visibility constraints; otherwise use heap/unknown.
atomic store address Mutator/address use.
cmpxchg address Mutator/address use.
cmpxchg replacement value carrying root A global, TLS, non-local, or unknown destination is a heap sink. A local replacement remains unknown in Phase 1 because the store-only exact-copy query does not prove its copies.
atomicrmw address Mutator/address use; a pointer-valued operand is unknown unless the operation is explicitly modeled.
volatile memory access Volatility alone does not retain an address, but it prevents removing/reordering required operations during rewrite.

Phase 1 follows a root through a local slot only when the exact-copy query proves the complete copy set. It does not construct general memory def-use chains, scan the CFG, or reason about partial and may-alias stores. Any non-exact access, unsupported reader, unknown clobber, or unresolved destination makes the root escape.

Returns and externally visible storage

LLVM use Phase 1 rule
ret operand, allocation root Returned, therefore not stack-rewritable.
ret operand, parameter root Record result index/component and dereference level.
store into global, TLS, externally visible aggregate, or unknown memory Heap sink.
global initializer Not a function-local allocation candidate. Any referenced local root is invalid/unknown.

Calls and invokes

LLVM use Phase 1 rule
direct call argument to a defined function Accept only when the corresponding NoCapture map entry is true. Record diagnostic flow separately in locationGraph.
direct Go/runtime helper with semantic rule Apply the LLGo semantic rule.
direct declaration without summary Unknown/capturing argument.
indirect call argument Unknown/capturing argument.
root used as indirect callee Record callee effect and treat unknown closure context conservatively.
invoke Same call rules; normal/unwind control edges do not change capture classification.
callbr Unknown unless the complete target and operand contract is modeled.
inline assembly operand Unknown unless a specific constraint is proven non-capturing and covered by tests.
LLVM lifetime/debug/assume intrinsic Safe terminal use when the intrinsic contract does not capture the pointer.
unknown intrinsic Unknown.

Phase 1 uses the internal NoCapture map only for defined functions. Bodyless C and runtime declarations remain capturing; a separate NoFree analysis is not part of this phase. Generic attributes still do not describe Go aggregate components or provide Go result leak levels.

Integer and pointer conversions

LLVM use Phase 1 rule
ptrtoint Unknown/escaping in Phase 1. Pointer identity and GC visibility are lost.
inttoptr derived from a tracked round trip Unknown in Phase 1.
integer arithmetic on a pointer-derived integer Unknown.
pointer comparison Safe terminal observation.

A Phase 2 uintptr rule may preserve identity through a verified local round trip, but arithmetic, storage, calls, or untracked merges terminate it conservatively.

Memory intrinsics

LLVM use Phase 1 rule
allocation pointer as memset destination Safe mutating address use when size is within the allocation.
allocation pointer as memcpy/memmove destination Safe address use for the allocation itself; copied pointer content needs typed semantics.
allocation pointer as copy source Safe borrowed address use for allocation identity.
root stored inside bytes being copied Unknown unless LLGo type metadata proves the pointer-containing fields and destination lifetime.
object size, lifetime, invariant, and debug intrinsics Apply the documented LLVM intrinsic contract; otherwise unknown.

Control-only and exceptional instructions

Branches, switches, indirect-branch addresses, landing-pad values, resume, cleanup, and unreachable do not retain an unrelated pointer merely by controlling execution. If a root is itself an operand to an exceptional or control instruction not covered above, classify that operand explicitly; do not assume it is safe from opcode category alone.

Deallocation

LLGo GC allocations are not manually freed through ordinary Go code. A call that may free an allocation is not stack-rewritable unless the pass has a verified matched-allocation/deallocation contract. AllocRoot/FreeRoot remain excluded from Phase 1 heap-to-stack conversion.

Phase 2 Go semantic rules

Phase 2 replaces conservative call/instruction boundaries with verified LLGo semantics. Rules should be represented as transfer functions owned near the lowering/runtime contract, not scattered string comparisons inside the generic walker.

Channels and select

  • The address passed to runtime.ChanSend is borrowed until the helper returns.
  • Pointer-containing element content may be copied into channel-owned storage and therefore escapes.
  • Buffered send copies into the channel buffer.
  • Unbuffered send may queue the temporary address while blocked, but ChanSend does not return until the transfer no longer needs it.
  • Receive copies element content into caller-provided storage.
  • Select joins every possible send/receive state and preserves the same address-versus-content distinction.
  • close, len, and cap do not retain the channel handle.

Maps

  • MapAssign borrows key lookup data for the call but stores key/value content in map-owned storage.
  • Map lookup borrows the map handle and may return/copy stored value content.
  • Iterators retain the map according to their runtime lifetime and expose key/value content.
  • delete and clear do not require a flow-sensitive strong deletion; retaining prior content facts is conservative.
  • Proving a map handle local does not by itself authorize stack allocation of the runtime map representation.

Slices, arrays, strings, append, and copy

  • A slice carries backing-storage identity independently from length/capacity scalars.
  • Slice and slice-to-array-pointer operations preserve backing identity.
  • append may reuse existing storage or allocate new storage; capacity information controls whether either case can be excluded.
  • Appended pointer content flows into the result backing storage.
  • copy, SliceCopy, Typedmemmove, memcpy, and memmove propagate Go pointer fields only with verified type/layout information.
  • Untyped copies involving possible Go pointers remain unknown.
  • String/byte/rune conversions must be classified from LLGo lowering as borrow, copy, allocation, or unknown.

Interfaces and type assertions

  • Use the same ABI classification as ssa/interface.go to distinguish direct and indirect payloads.
  • Direct pointer payloads preserve pointer identity.
  • Indirect payloads are copied into an interface box, so pointer-bearing fields flow into that box.
  • Interface-to-interface changes preserve payload facts.
  • Concrete type assertions recover the modeled payload.
  • Interface dispatch uses callee summaries only when all possible targets are soundly known.

Closures and method values

  • Closure lowering must identify the environment allocation and captured components.
  • A closure environment escapes when the closure value is returned, stored, passed to unknown code, or started as a goroutine.
  • An immediately called known closure composes the closure body summary with captured values.
  • Bound method values apply the same rule to the receiver.
  • A bare function code pointer has no environment lifetime.

Goroutines

ssa/goroutine.go copies function and argument data into an AllocRoot startup record. The new goroutine may outlive the caller, so every current-frame allocation reachable from those values escapes even if the callee does not retain the parameter after startup. The startup root allocation itself is excluded from ordinary heap-to-stack conversion.

Defers, panic, and recover

  • A normal defer retains arguments until the current function exits, which may still be frame-bounded.
  • Conditional and loop defers must follow LLGo's actual save/drain representation.
  • A deferred closure retains captured values to its execution point.
  • A panic value may be recovered by an older frame and crosses the current frame lifetime.
  • Recover results remain unknown unless a sound context relation is introduced.

External calls, C, and directives

  • A bodyless declaration without a trusted contract remains unknown.
  • //go:noescape establishes non-retention only for the declared parameter contract; it does not imply readonly memory or a result relationship.
  • //go:uintptrescapes and implicit uintptr keepalive require explicit summary effects.
  • LLVM nocapture, nofree, readonly, and memory-effect attributes may be consumed only according to their documented meanings.
  • C ABI transformation may change parameter representation, which is why Go summaries should be computed before C ABI rewriting.
  • Callbacks and function pointers require callee effects and conservative lifetime handling.

GC/runtime variants

The pointer lifetime proof is independent of BDWGC, TinyGo-derived GC support, or non-GC targets. The rewrite mechanism may differ by target/runtime, but no implementation may retain a pointer into a reclaimed stack frame. Semantic helper rules must be valid for every runtime implementation to which they are applied.

Heap-to-stack rewrite

Eligible allocations

Phase 1 initially recognizes direct LLGo allocation calls whose contracts are fully known:

  • runtime.AllocZ(size) for zero-initialized GC allocation;
  • runtime.AllocU(size) for uninitialized GC allocation, if all existing uses permit replacement.

AllocRoot and helpers with ownership, finalization, profiling, identity, or runtime registration semantics remain excluded until explicitly proven equivalent.

Rewrite requirements

A local candidate is rewritten only when:

  • the allocation size is known and within LLGo's stack policy;
  • required alignment is known and representable;
  • the call is not in a loop/cyclic region whose alloca placement would merge identities or grow stack unboundedly;
  • every transitive use is classified local at the fixed point;
  • no use may free, publish, return, or retain the pointer;
  • zero initialization for AllocZ is reproduced exactly;
  • target address space and pointer representation permit an alloca replacement.

The transformer creates the alloca at a verified placement, reproduces initialization, replaces allocation-call uses, and deletes the call. The module is verified immediately after rewriting.

Loop allocations

An allocation instruction inside a loop represents a fresh object per dynamic execution. Hoisting one alloca to entry may merge identities; creating dynamic allocas in the loop may grow stack until function return. Phase 1 skips these candidates. A later optimization requires an explicit lifetime/placement proof.

Zero-sized allocations

LLGo has existing zero-sized allocation behavior. The pass must preserve permitted pointer-identity behavior and may not replace a shared/sentinel heap representation with a distinct stack address without verifying semantic equivalence.

Diagnostics reflect final lowering

Analysis locality is not enough to print that an object moved to stack. If size, alignment, loop, target, or ABI policy rejects the rewrite, diagnostics must report the final heap decision and reason.

Diagnostic path graph

The optimization solver needs only allocation use states and the boolean NoCapture map. A separate locationGraph may retain pointer-flow edges and one predecessor per diagnostic fact improvement:

allocation or Go parameter source
    -> LLVM instruction and operand role
    -> derived LLVM SSA value
    -> direct-call or Go semantic transfer
    -> result/heap/unknown sink

This explanation graph must satisfy:

  • disabled unless detailed diagnostics are requested;
  • no influence on traversal, the NoCapture fixed point, or rewrite decisions;
  • deterministic tie-breaking by source position and LLVM instruction order;
  • cycle detection and bounded output;
  • source positions resolved through LLGo lowering metadata;
  • deletion of all explanation state leaves results unchanged.

Concise reports are formatted from structured facts:

p does not escape
leaking param: p
leaking param content: p
leaking param: p to result ~r0 level=0
moved to heap: x

Exact wording and column compatibility are formatter work, not solver logic.

Failure and fallback behavior

Unknown real program behavior makes only the affected root escape. An unclassified LLVM opcode/operand role is a pass implementation gap and must fail focused exhaustive tests; production handling remains conservative until classification exists.

An internal inconsistency must not produce a partial optimistic result. Resource limits, missing metadata, unavailable imported summaries, unsupported address spaces, and failed rewrite preconditions all retain the heap allocation.

Imported summaries must be versioned with compiler/cache inputs before being reported. They do not affect Phase 1 heap-to-stack decisions; a callee without a local NoCapture entry remains capturing.

Module ownership

  • A new internal escape-transform package owns LLVM use traversal, the NoCapture fixed point, diagnostic locationGraph, Go semantic transfer dispatch, rewrite planning, and structured results.
  • internal/build invokes the transformer at the post-generation module boundary and owns ordering with llabi, cabi, verification, caching, and diagnostics collection.
  • cl emits only the minimum source/Go semantic associations that cannot be recovered from LLVM IR. It does not perform escape analysis.
  • ssa continues to lower Go operations and owns runtime helper contracts used by semantic rules. It does not own the analysis worklist.
  • internal/meta transports stable imported summaries after the format is approved.
  • cmd/internal/compile formats -m output from structured results.
  • Generic LLVM passes remain independent consumers of the rewritten module.

The package API should remain unexported. The transform should accept the live module plus the minimum immutable LLGo facts it actually consumes.

Performance

The base cost is proportional to transitive LLVM use paths visited for each allocation and parameter root, plus boolean NoCapture fixed-point rescans. This repeats some instruction visits across roots but keeps the analysis local, conservative, and independently testable.

Optional statistics should include:

  • allocation and parameter root count;
  • visited LLVM uses per root;
  • exact-copy queries, successful forwarded copies, and conservative rejection reasons;
  • early allocation rejection reasons;
  • NoCapture state changes and fixed-point rescans;
  • unknown call, store, cast, intrinsic, and semantic-helper boundaries;
  • bound-triggered fallbacks;
  • successful rewrite count and rejected-local policy reasons;
  • explanation nodes only when detailed diagnostics are enabled;
  • total time and retained memory.

The pass runs when optimization or escape diagnostics consume its results. A diagnostics-only request must not enable unrelated LLVM optimization pipelines.

Testing strategy

LLVM traversal tests

Construct focused LLVM modules and test:

  • load and store-address safe uses;
  • pointer stored as value with a successful exact local copy;
  • exact-copy rejection for global, non-local, aliased, clobbered, and unsupported readers;
  • GEP, cast, phi, select, aggregate, and vector propagation;
  • pointer/int conversions;
  • atomic and volatile uses;
  • direct, indirect, invoke, intrinsic, and inline-assembly calls;
  • return and global sinks;
  • cycles and resource bounds;
  • every supported LLVM opcode/operand role;
  • deterministic explanation paths.

Summary tests

  • direct heap and result summary composition;
  • dereference levels 0, 1, and saturation;
  • mutator and callee facts;
  • a callee NoCapture entry changing after its caller was first analyzed;
  • direct and mutual recursion with and without a real sink;
  • imported, missing, stale, dynamic, and external summaries;
  • mapping LLVM aggregate parameters/results back to Go indices.

Rewrite tests

  • AllocZ becomes an alloca plus equivalent zero initialization;
  • AllocU becomes an uninitialized alloca;
  • escaping allocation calls remain;
  • size, alignment, loop, zero-size, address-space, and target rejection;
  • LLVM verifier success after rewrite;
  • compatibility with LowerLargeAggregates and all C ABI modes;
  • compatibility with debug metadata and later default LLVM passes.

Go semantic tests

Each semantic rule needs a local and an escaping neighbor:

  • channel temporary transport address versus sent pointer content;
  • map handle versus stored key/value content;
  • direct versus boxed interface payload;
  • immediately called versus returned closure;
  • slice alias/reallocation and typed copy;
  • goroutine startup lifetime;
  • frame-bounded defer and cross-frame panic;
  • noescape, uintptr, C, and callback contracts.

Differential -m tests

Compare structured facts with focused go tool compile -m cases:

allocation: local / returned / heap
parameter -> heap at level N
parameter -> result R at level N
parameter -> mutator at level N
parameter -> callee at level N

Go output is an oracle for Go language semantics, not proof that an LLGo runtime helper has the same implementation contract.

During development, run focused package/pass tests and the exact GOROOT errorcheck cases affected by each change. Broader suites belong at integration milestones.

Staged implementation

Phase 1A: post-generation LLVM analyzer

  • Add the internal module transformer and invoke it after cl emits the complete module.
  • Recognize eligible AllocZ/AllocU calls and Go parameter roots.
  • Implement LLVM use traversal, operand-role classification, OnlyExact copy forwarding, visited state, bounds, and structured results.
  • Do not rewrite yet.

Phase 1B: NoCapture and parameter summaries

  • Compute the boolean per-function-parameter NoCapture map to a finite fixed point for direct-call heap-to-stack decisions.
  • Record heap, result, mutator, and callee flows in locationGraph for summaries without feeding them back into optimization.
  • Add minimal lowering/source associations needed for -m.
  • Keep dynamic, imported-without-summary, and external calls conservative.

Phase 1C: heap-to-stack rewrite

  • Plan and apply AllocZ/AllocU to alloca rewrites.
  • Preserve size, alignment, initialization, loop, zero-size, target, and address-space constraints.
  • Verify the module after rewriting.
  • Test ordering with large-aggregate and C ABI transforms.

Phase 1D: diagnostics and imported summaries

  • Format concise -m output from structured facts.
  • Add optional explanation predecessors.
  • Add versioned package summary transport through the existing metadata/cache boundary.

Phase 2: Go semantics

Land each family independently:

  1. typed memory operations and basic slice aliases;
  2. closures and immediately called function values;
  3. interfaces and assertions;
  4. append/copy and string conversions;
  5. channels and select;
  6. maps and iterators;
  7. frame-bounded defers and panic/recover;
  8. external summaries, //go:noescape, and uintptr contracts;
  9. sound dynamic-call target sets;

Each change removes one named conservative boundary and includes a nearby retaining case that must still escape.

Alternatives considered

Port Go's escape analysis

Rejected because the implementation is centered on cmd/compile AST/compiler IR and is tightly coupled to Go frontend concepts. Porting it would be large, difficult to validate, and costly to maintain.

Use LLVM AAHeapToStack alone

Rejected as the complete solution because it cannot produce LLGo's required parameter leak summaries and cannot apply Go/runtime-specific semantics. It may remain a later generic optimization for allocation opportunities outside the LLGo pass's modeled source/runtime contracts.

Analyze x/tools go/ssa

Rejected as the final analysis layer. It would make the escape result depend on a pre-lowering representation and then require a separate mapping to the complete LLVM module for transformation. LLGo already needs an LLVM rewrite pass; analyzing the final module keeps the proof and rewrite on the same SSA values while semantic metadata supplies only facts LLVM cannot express.

Parse LLVM diagnostics or debug logs

Rejected because pass text is not a stable API, failed conversions carry no complete explanation, and the required Go summaries do not exist in the output.

Implement the pass in xgo-dev/llvm

Rejected initially. The logic is LLGo-specific and can be implemented as an ordinary Go module transformer using the existing LLVM bindings, following internal/cabi. Binding changes are justified only if a concrete required LLVM API is missing.

Open questions

  • What is the minimal lowering side table/metadata needed to map LLVM parameters, results, allocations, and semantic helpers back to Go concepts?
  • Should the pass run strictly before LowerLargeAggregates, or are any allocation shapes introduced later that require a second narrow scan?
  • Which allocation helpers beyond AllocZ and AllocU have contracts safe for Phase 1 rewriting?
  • What component/dereference/use-state limits fit LLGo's largest current packages?
  • Which summary facts must be transported in the first cross-package release?
  • How should generic instantiations and wrappers be identified in summaries?
  • Which LLVM attributes are sufficient to consume external noescape facts without misrepresenting Go aggregates?
  • Can any loop allocation be rewritten without identity merging or unbounded stack growth?
  • What narrow pass boundary should expose LLVM's OnlyExact potential-copy query to LLGo without enabling all of Attributor?
  • Should LLGo still enable AAHeapToStack after its own pass to recover generic IR-level opportunities?

Acceptance criteria

Phase 1 is complete when:

  • the analyzer runs on the complete cl-generated LLVM module at the post-generation transform boundary;
  • every relevant LLVM opcode and operand role has a focused tested classification;
  • exact local copies remain in the use worklist, while every incomplete exact-copy proof remains conservative;
  • unknown uses and failed rewrite preconditions retain heap allocation;
  • direct and recursive calls reach stable heap/result summaries;
  • summaries distinguish direct leaks, content leaks, result indices, and dereference levels;
  • proven-local eligible allocation calls are rewritten to equivalent allocas;
  • module verification and later large-aggregate/C ABI/default LLVM transforms succeed;
  • detailed explanation recording can be disabled without changing any result;
  • focused differential tests document every remaining conservative Go-semantic mismatch.

Phase 2 is incremental. A semantic family is complete only when its previous unknown boundary is removed, its LLGo lowering/runtime contract is tested, and a nearby escaping case remains conservative.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions