Interprocedural Analysis
Facts that cross a function boundary. The dial is context sensitivity — whether two call sites of the same function get one answer or two — and every notch of precision is paid for in compile time. Summaries are the compromise everything real is built on.
How does a tool know anything about a value that was computed in a different function?
The program as a call graph whose nodes each carry a *summary*: a compact description of what the function does to the facts the analysis cares about — which parameters it may write through, whether it can return null, whether it can throw, whether a parameter escapes, which globals it touches. The summary is the representation that makes this tractable: it replaces "re-analyse the callee at every call site" with "look up what the callee does", and the entire design space is how much of the calling context the summary is allowed to depend on.
An interprocedural analysis may assume the call graph it was given is a sound over-approximation of the calls that can occur — miss an edge and every summary applied downstream is applied to an incomplete picture. Applying a summary at a call site is valid only if the summary was computed under assumptions that hold at that site: a context-insensitive summary must be valid for *every* caller, so it must be the join over all of them; a context-sensitive summary is valid only at the context it was computed for. Applying a context-sensitive summary at the wrong context is not an imprecision, it is an unsoundness — and it is the classic implementation bug in this area.
Key points
- Without interprocedural facts, an analysis must assume every call may write anything and return anything, and precision collapses after the first call.
- The three options are inline-then-analyse, re-analyse per call site, and summarise once — and everything real is built on summaries.
- Context-insensitive means one summary per function, valid for all callers, which merges facts across unrelated call sites.
- Context-sensitive means one summary per calling context, which removes unrealizable paths and multiplies the work down the call chain.
- Conditional summaries — "returns Positive if both arguments are Positive" — recover most context sensitivity without duplicating the analysis.
- Summaries are computed bottom-up in reverse topological order over the call graph; mutual recursion is an SCC iterated to a fixed point.
- Object sensitivity beats call-site sensitivity on object-oriented code at similar cost, so the right context abstraction is language-shaped.
- Separate compilation is the structural obstacle: LTO, cross-module IR and runtime JIT compilation are three different ways of paying to remove it.
Where intraprocedural analysis stops
Every analysis in this module so far assumed the function it was looking at was the whole world. That assumption breaks at the first call. What does parse(input) return — can it be null? Does configure(&opts) write through that pointer? Does run(cb) invoke cb before or after acquiring the lock? Does this callee free the buffer I passed it? An intraprocedural analysis must assume the worst at every call: the callee may write every reachable location, may return anything, may throw, may not return. That assumption is sound and it is devastating for precision — after two calls, an analysis that started with useful facts knows nothing.
The options are exactly three. Inline first and analyse afterwards, which is precise and only works for small callees and non-recursive code, and is the reason [[inlining]] is described as the enabling transformation rather than an optimization in its own right. Re-analyse the callee at each call site, which is maximally precise and multiplies the work by the number of call sites, recursively. Or compute a summary once and apply it everywhere, which is what real systems do.
The interesting question is what the summary is allowed to know about who is calling.
Context sensitivity: one answer or many
-fipa-cp adds a limited form of context sensitivity by cloning a function for a constant argument, which is context sensitivity implemented as duplication. Dedicated whole-program analysers such as Doop or WALA offer real context sensitivity and take orders of magnitude longer. Neither approach is "the" right one; they sit at different points on the same curve.A context-insensitive analysis computes one summary per function, valid for every caller. It is fast — one pass per function — and it merges. If id(x) is called once with a non-null value and once with a possibly-null value, its single summary says "may return null", and both call sites get the pessimistic answer even though one of them provably cannot see null. Classically this is described as analysing an *unrealizable path*: the analysis effectively considers "enter from call site A, return to call site B", which no execution ever does.
A context-sensitive analysis keeps a separate summary per calling context, so each site gets its own answer and unrealizable paths disappear. The cost is that the number of contexts multiplies down the call chain: a function called from ten places, each of which is called from ten places, has a hundred contexts at depth two. This is the same exponential as [[control-flow-analysis]], for the same reason, and the standard defences are the same: bound the depth (k-limiting), or pick a smarter notion of context than "the call site" — the receiver object's allocation site, or the types of the arguments.
The example below is the smallest thing that shows the difference, and the shape it has — a small generic helper used from many places with different assumptions — is exactly the shape modern code is written in.
1int scale(int v, int k) { return v * k; }2 3int a(void) { return scale(2, 3); } /* both operands positive */4int b(void) { return scale(x, -1); } /* sign of x unknown */5 6/* context-insensitive summary of scale:7 * returns Top (join over all callers)8 * -> a() is Top too, even though 2*3 is obviously positive9 *10 * context-sensitive (k=1):11 * scale@a returns Positive12 * scale@b returns Top13 * -> a() is Positive, and constant folding can finish the job14 */Note what the imprecision costs downstream: with the insensitive summary, a range analysis in a cannot prove the result is positive, so a bounds check that depended on it survives, so a loop does not vectorize. One merged summary three levels down is how a missing optimization gets its cause. This is also why aggressive inlining recovers so much: inlining scale into a makes the whole question intraprocedural again.
Summaries: the compromise everything is built on
A summary is a function from what the analysis knows at the call site to what it knows afterwards, small enough to store and cheap enough to apply. The classic bottom-up scheme computes them in reverse topological order over the call graph: analyse the leaves, summarise, move up. Mutual recursion forms a strongly-connected component, which is handled by iterating the whole component to a fixed point — starting from an optimistic assumption and weakening until stable.
What goes in a summary is a design decision with the same character as choosing an abstract domain. Common contents: which parameters are dereferenced unconditionally (so a null argument is definitely a bug), which parameters may be written through, whether the function may throw and what, whether an argument escapes into the heap or into another thread, purity, the return value's abstract element, and a set of conditions under which each of these holds.
That last one — conditional summaries — is what makes summary-based analysis competitive with context sensitivity without paying for it. Instead of "returns Top", the summary says "returns Positive if both arguments are Positive". The summary is computed once, and each call site instantiates it with its own facts. Infer's separation-logic summaries and GCC's value-range propagation across calls both work this way, and it is the single highest-leverage idea in the area: parameterise the summary rather than duplicating it.
| Strategy | Cost model | Precision | Used by |
|---|---|---|---|
| Assume the worst at every call | Free | Almost none across boundaries | Any single-file linter or fast IDE check |
| Inline, then analyse | Code size grows; compile time grows with it | Exact, for what was inlined | Every mainstream optimizing compiler |
| Context-insensitive summaries | One pass per function | Merged across callers; unrealizable paths | GCC IPA, LLVM interprocedural passes |
| Conditional summaries | One pass per function, larger summaries | Near context-sensitive where the condition is expressible | Infer, GCC value-range across calls |
| k-limited context sensitivity | Multiplicative in k, exponential in the limit | Separates callers up to depth k | Doop, WALA, research analysers |
| Object/type sensitivity | Comparable to k=1 call-site, better results on OO code | Separates by receiver allocation site | Doop for Java; the current best practice for OO |
| Whole-program re-analysis | Multiplicative in call sites, recursively | Maximal | Nothing at production scale |
The separate-compilation problem
There is a structural obstacle that no amount of algorithmic cleverness removes: interprocedural analysis needs to see the callee, and [[separate-compilation]] exists precisely so that it does not have to. When each translation unit is compiled alone, a call into another unit is opaque, and the analysis is back to assuming the worst.
The industry answers are all variations of "defer the analysis until more is visible". [[link-time-optimization]] serialises the IR into object files and runs the interprocedural passes at link time, when the whole program is present. Header-only libraries and C++ templates side-step it by putting the body in the caller's translation unit. Rust and Swift emit cross-crate/cross-module IR for the same reason, and #[inline] in Rust is a request to make a body available across a crate boundary rather than a hint about inlining per se. Java sidesteps the whole issue by doing the analysis at runtime, where the entire loaded program is visible.
Each of these buys precision and pays it back in build structure: LTO makes the link step the bottleneck and defeats fine-grained incremental rebuilds, header-only libraries multiply compile time across every consumer, and cross-module IR grows the artifact. This is the real reason interprocedural analysis is rarer in practice than its value suggests — the algorithmic cost is only half the bill, and the other half lands on the build system.
- Within a translation unit, interprocedural analysis is nearly free and mainstream compilers do it by default.
- Across translation units it requires LTO, which moves the cost to link time and hurts incremental builds —
[[incremental-compilation]]and this are in direct tension. - Across library boundaries it requires the library to ship IR or source, which is a distribution decision, not a compiler one.
- A JIT gets it for free at the cost of doing it repeatedly at runtime and needing deoptimization when an assumption stops holding.
- A closed-world analyser (native image, whole-program bundler) gets it by refusing to be open — which is the same trade under a different name.
How it works
The steps, in the order the compiler takes them.
- Build a call graph and condense it into strongly-connected components, giving a DAG of components.
- Process components in reverse topological order so that every callee is summarised before its callers are analysed.
- For a single-function component, analyse the body with the summaries of its callees applied at each call site, then abstract the result into a summary.
- For a multi-function component (mutual recursion), initialise each member optimistically, iterate the whole component, and stop when no summary changes.
- Decide the context abstraction: none, the call site, the last k call sites, the receiver's allocation site, or the abstract types of the arguments.
- Where the summary can be made conditional on facts about the arguments, do so rather than instantiating separate contexts — the same precision at a fraction of the cost.
- At each call site, instantiate the callee summary with the caller's facts: bind parameters, translate the effect on the heap into the caller's naming, and apply the return fact.
- For calls whose target is unknown or external, apply a conservative default summary — writes everything reachable, returns Top, may throw — and record that as an imprecision source rather than silently proceeding.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A function that provably cannot receive null still carries a null check in the generated code, because one unrelated caller in a different file passes an optional and the summary was merged over both.
- Turning on LTO turns a two-minute incremental build into a twelve-minute one, and the team turns it off, losing every cross-module optimization with it.
- A whole-program analyser reports the same finding at forty call sites of one shared helper, because a context-insensitive summary propagated one caller's taint to all of them.
- A recursive component fails to converge because the summary lattice has no finite height and nobody added widening, so the analysis either loops or is cut off by a timeout that produces no diagnostic.
- A summary computed for one context is cached and reused at another by an off-by-one in the context key, and the analysis reports "cannot be null" about a value that can — unsound, silent, and only visible as a crash much later.
- An analysis that looks precise in a single-file test becomes useless on the real project, because the real project calls into libraries with no summaries and every call resets the state to Top.
When it helps
- Null and resource analysis, where the fact that matters — "this function dereferences its first parameter unconditionally" — is exactly a summary and is cheap to compute and store.
- Escape analysis and allocation elimination, which are interprocedural by nature: whether an object escapes depends on what every callee does with it. See
[[escape-analysis]]. - Constant propagation into library and helper functions, where GCC's
-fipa-cpcloning turns a generic function into a specialised one for the arguments it actually receives. - Taint tracking for security, which is fundamentally an interprocedural question — the source and the sink are almost never in the same function.
- Devirtualization and inlining decisions, which need to know what the callee costs and does before deciding whether crossing the boundary is worth it.
When it hurts
- On a fast edit-compile loop. Interprocedural passes are the first thing to disable when compile time matters, and a language server will not run them at all.
- In codebases dominated by dynamic dispatch and framework callbacks, where the call graph is dense, summaries merge everything, and the analysis produces Top at scale.
- When incremental correctness matters: a summary depends on the callee, so changing one leaf function invalidates every summary above it, and naive caching produces stale results.
- Where the code crosses a language boundary — FFI, JNI, a native module — and there is simply no body to summarise, so the conservative default dominates the result.
What it costs
Every one of these is paid by something.
- Context sensitivity buys per-caller precision and pays a multiplicative and eventually exponential increase in analysis time and memory, which is why almost everything ships k ≤ 1.
- Summaries buy a linear-ish cost model and pay in expressiveness: whatever the summary language cannot say is lost at every call site, permanently.
- Inlining before analysis buys exact interprocedural facts and pays in code size, instruction-cache pressure and compile time, and it cannot be applied to recursion or to large callees.
- LTO buys cross-module analysis and pays with link-time serialisation, memory at link, and the loss of a genuinely incremental build.
- Conditional summaries buy near-context-sensitive precision and pay in summary size and in the implementation complexity of instantiating a condition correctly at every site — the place where unsound reuse bugs live.
- A conservative default for unknown callees buys soundness and pays by making one unanalysable call poison every fact in its caller, which is why model coverage of libraries matters more than the algorithm.
What else you could do
What a different compiler or language does instead, and when that is better.
- Rely on inlining and give up on the rest. This is what most production compilers effectively do, and it is a defensible engineering choice: cheap, predictable, and it covers the small hot callees that matter most.
- Move the fact into the type or annotation system so it does not need to be inferred:
@NotNull,noexcept,pure,const, ownership, effects. The programmer supplies the summary — see[[effect-systems]]and[[ownership-types]]. Cheaper, checkable, and it costs annotation burden. - Analyse at runtime, where the whole loaded program is visible and the actual types are known, and pay in warmup and in the need to deoptimize — see
[[jit-compilation]]. - Demand-driven analysis: instead of summarising everything, answer only the specific query the user asked, following the call graph backwards from the point of interest. This is what makes an IDE feature such as "find implementations" feasible at all.
- Modular analysis with declared interfaces, where each module publishes its summary as part of its interface and callers use it without seeing the body. This is what
[[interface-files]]do for types, applied to effects, and it preserves separate compilation at the cost of a bigger interface language.
See it for yourself
The flag, dump or tool that shows you this directly.
- GCC:
-fdump-ipa-allwrites every interprocedural pass's state;-fipa-cp -fipa-cp-cloneperforms constant propagation with cloning and-fdump-ipa-cp-detailsshows which clones it made and why;-fipa-ptaenables interprocedural points-to analysis (off by default because of its cost). - LLVM:
opt -passes='default<O2>' -print-after-allshows the interprocedural passes running;-passes=function-attrsis the pass that infersreadnone/readonly/nocapture— the attributes are summaries, and you can read them straight off the IR after it runs. - Read the summaries directly in LLVM IR: parameter attributes such as
nocapture,readonly,noaliasand function attributes such asmemory(none)are exactly what an interprocedural analysis inferred, written down. - LTO:
clang -flto=thin(or-flto) plus-Rpass=inlineto see which cross-module inlines happened;llvm-lto2 --print-summaryinspects the ThinLTO summary index, which is a summary-based interprocedural design you can look at. - Java/Android:
infer run -- ./gradlew assembleDebugruns a summary-based (bi-abduction) interprocedural analysis and caches summaries ininfer-out/specs, which you can read to see what it inferred per method. - Rust:
cargo build --releasewith-Ccodegen-units=1and-Clto=fatmaximises cross-crate analysis;cargo llvm-linesshows what monomorphization and inlining actually produced. - Go:
go build -gcflags='-m -m'prints escape-analysis reasoning per allocation, including why a value escaped through a call — a readable example of an interprocedural fact affecting codegen.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Interprocedural means the compiler looks at the whole program." It means it looks past one function. Whole-program is a stronger and much rarer condition, and needs LTO or a closed world to be true at all.
- "Context-sensitive analysis is more accurate, so tools should use it." It is more accurate and exponentially more expensive, and the accuracy only materialises where callers genuinely differ. Most production toolchains buy the same precision more cheaply with inlining.
- "Summaries lose precision because they are approximations." A conditional summary can be as precise as re-analysis for the facts it can express. The loss is in expressiveness of the summary language, not in the idea of summarising.
- "If it works within a file it will work across files." Across a translation unit boundary the body is not there. Without LTO or shipped IR the analysis has nothing to summarise and falls back to the worst case.
- "The analysis is slow because the algorithm is bad." Usually it is slow because the call graph is dense or the context abstraction is too fine. Both are modelling choices, and both are fixable without touching the algorithm.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Most analyses stop at the edge of a function: past a call, they have to assume the worst. Interprocedural analysis is what gets facts across that edge, and the practical mechanism is a summary — a small description of what a function does to what you care about, computed once from the bottom of the call graph upward. The one dial that matters is whether a function gets one summary for all its callers (fast, but merges unrelated facts) or a separate one per caller (precise, and the count multiplies down the call chain).
practical
When you are chasing a missing optimization, the question to ask is which fact failed to cross which boundary. -Rpass-missed=inline, -fdump-ipa-cp-details and go build -gcflags="-m -m" all answer it directly. The two most common answers are that the callee was in another translation unit (fix: LTO, or make it inline-visible) and that one unrelated caller pessimised the shared summary (fix: split the function, or let cloning specialise it). And when the fix is LTO, measure the link time before committing to it — the build cost is real and it is what makes teams revert.
advanced
The literature's framing is the IFDS/IDE family: encode the interprocedural problem as a graph-reachability question over an exploded supergraph, where valid paths are constrained to those with matched calls and returns. That constraint is what eliminates unrealizable paths and it buys full context sensitivity in polynomial time — for the specific class of problems whose flow functions distribute over the merge operator. The practical significance is that context sensitivity is *not* inherently exponential; it is exponential for general lattices and polynomial for distributive ones, which is why null and taint analyses can afford it and value-range analysis cannot. Knowing which class your problem is in tells you whether precision is affordable before you build anything.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
-O2/-O3 today. It is not a rule: HotSpot performs a form of context sensitivity by inlining aggressively at runtime with profile data, and dedicated analysers such as Doop are context-sensitive by design and take minutes to hours on programs a compiler handles in seconds. The right comparison is always cost-per-question, not "which is better".-fipa-pta off by default on cost; -fipa-cp-clone is enabled at -O3 but not -O2; LLVM's ThinLTO summarises rather than merging IR, so it makes different precision/scalability trades than full LTO. A result observed under one configuration does not transfer to another configuration of the same compiler, let alone to a different one.noexcept, pure, @NotNull — shift this obligation onto a check at the definition, which is why those annotations must be verified rather than trusted.If you were asked this in an interview
- What is the difference between a context-sensitive and a context-insensitive interprocedural analysis, and what does each cost?
- What would you put in a summary for a null-safety analysis, and what would you deliberately leave out?
- Why does turning on LTO often hurt more than it helps, and how would you decide?
Connections
- DevOps / Production Engineering — The build-time cost of link-time optimization and how it interacts with caching and incremental buildsThe reason interprocedural analysis is often disabled is not that it fails to help but that it destroys incremental rebuild and slows the link step past what a delivery pipeline tolerates. Budgeting that, and deciding whether to run it only on release builds, is a delivery-engineering decision owned there; what precision is being given up is ours.