Control-Flow Analysis
Which statements can run in which order, and — the genuinely hard case — which functions a call site can actually reach when the callee is a value. In a higher-order language you cannot build the call graph without the analysis, and cannot run the analysis without the call graph.
When the thing being called is a variable, how does any tool know what runs?
Two graphs at two scales. Inside a function, the control-flow graph: basic blocks and the edges between them, which is [[control-flow-graph]] and is fully determined by the syntax. Across functions, the call graph: nodes are functions, edges are "this call site may invoke that function" — and in any language with function values, virtual dispatch or dynamic loading, this graph is not given by the syntax. It is the *output* of an analysis, and every interprocedural question depends on having it.
Intraprocedural control-flow analysis may assume the frontend produced a well-formed CFG: every block ends in a terminator, every edge lands on a real block, and there is no path into the middle of a block. Call-graph construction may assume nothing comparable. It must treat every indirect call as reaching *some* set of functions, and it is sound only if that set over-approximates every callee possible at runtime — which means it must account for every function whose address is taken, every override of a dispatched method, and every entry point reachable through reflection, dlopen, eval or a foreign-function boundary. Miss one and every downstream conclusion, including whether a function is dead, is wrong.
Key points
- Inside a function the CFG is determined by syntax; across functions the call graph is the output of an analysis, not a given.
- The exception,
finallyand coroutine edges are the ones people forget, and they are where resource-leak bugs live. - Control-flow analysis in the higher-order sense means determining which function values reach which call sites — control depends on data depends on control.
- k-CFA resolves the circularity by solving both as one fixed point, with
kcontrolling how much calling context each abstract value keeps. - 0-CFA merges all call sites of a shared combinator; 1-CFA separates them and costs an analysis copy per site.
- Cheap approximations — address-taken, CHA, RTA — are what production compilers usually run, because a static type already bounds the callee set.
- JITs replace the analysis with observation: an inline cache plus a guard is more precise than any static CFA and needs a deoptimization path.
- An unsound call graph does not produce a slow program, it produces a broken one — whole-program dead-code elimination deletes the reflectively-reached function.
The easy half: control flow inside a function
Within a single first-order function, control-flow analysis is a solved problem and largely a matter of bookkeeping. The frontend lowers statements into basic blocks, adds edges for branches, loops and fall-through, and the result is a graph you can run any of the classical algorithms over: reachability by depth-first search, [[dominators]] for what must execute before what, [[natural-loops]] for what iterates. Unreachable-code diagnostics, definite-assignment checks and "not all paths return a value" errors all fall directly out of it.
The details that catch people out are the edges nobody wrote. Exceptions add an edge from every potentially-throwing instruction to every enclosing handler and to the function exit, which is why [[exception-handling]] makes the CFG far denser than the source suggests and why resource-leak analysis is mostly about those edges. setjmp/longjmp, goto into a loop, computed gotos in an interpreter dispatch loop, and coroutine suspension points all add edges too. A CFG that models only the visible control flow is a CFG that will confidently tell you a finally block is unreachable.
All of this is genuinely local and genuinely cheap. Which is why the interesting content of this lesson is entirely in the other half.
The hard half: when the callee is a value
[[inline-caches]] and [[guards]]. Ahead-of-time compilers for statically typed languages get most of the benefit from cheaper approximations instead (class hierarchy analysis, rapid type analysis) because the type of the receiver already bounds the callee set. k-CFA in its general form appears mainly in whole-program analysers for functional and dynamic languages.Write f(x) where f is a parameter, a field, an entry in a dispatch table, a closure returned from somewhere, or an object whose method is virtual, and the question "what runs here" stops being syntactic. This is what the literature calls control-flow analysis proper, or CFA, and the naming is initially confusing: it is not about branches, it is about determining the flow of *control values* — functions treated as data — to the sites where they are invoked.
The circularity is what makes it hard. To know which functions a call site reaches, you need to know which function values flow into that position. To know which values flow anywhere, you need to know which functions run, because a function's body is what propagates values to its callees. Data flow depends on control flow depends on data flow. The resolution is the same as everywhere else in this module: solve both at once, as a single fixed point over a graph you are building while you traverse it.
The standard family of answers is k-CFA, where k is how much calling context each abstract value carries.
1function apply(f, v) {2 return f(v); // which function does this call?3}4 5apply(x => x + 1, 10); // site A: f is the increment closure6apply(x => x * 2, 10); // site B: f is the doubling closure7 8// 0-CFA: at `f(v)`, f may be {increment, double}. Both call sites merged.9// 1-CFA: at `f(v)` from A, f = {increment}; from B, f = {double}.With 0-CFA every use of apply shares one abstract binding for f, so a devirtualizer sees two possible callees and inlines neither. With 1-CFA the analysis keeps one abstract copy of apply per call site and each sees exactly one callee — at the cost of analysing apply twice. Now imagine apply is called from two hundred places and calls three other higher-order helpers, and you have the k-CFA cost curve.
The cheap approximations, and when each is enough
Because full CFA is expensive, real toolchains use a ladder of approximations and stop at the cheapest one that answers the question. It is worth knowing the ladder by name, because "the compiler could not devirtualize this" almost always means "the approximation in use could not narrow the callee set to one".
Each rung buys precision with either analysis time or a soundness caveat, and the last two buy it with a runtime check rather than a proof.
| Technique | Callee set for a virtual call | Precise enough when | Cost |
|---|---|---|---|
| Address-taken (for function pointers) | Every function whose address is taken anywhere with a compatible signature | Few function pointers exist | Trivial |
| Class Hierarchy Analysis (CHA) | Every override of the method in the static type's subtree | Shallow hierarchies; single implementation | One pass over the hierarchy |
| Rapid Type Analysis (RTA) | CHA restricted to classes actually instantiated in the program | A framework declares many types and the app uses few | One extra fixed point |
| 0-CFA | Functions flowing to that variable, merged across all calling contexts | Higher-order code without shared combinators | Roughly cubic in program size |
| k-CFA (k ≥ 1) | Per calling context, so shared helpers separate | Shared combinators such as map, apply, middleware chains | Exponential in k; rarely used past k=1 or 2 |
| Profile-guided | Whatever was observed, plus a fallback | The hot receiver dominates in production | A profiling run — see [[profile-guided-optimization]] |
| Inline cache (JIT) | Whatever appeared last, behind a guard | Call sites are monomorphic in practice, which most are | A guard per call plus deoptimization support |
What the call graph is actually for
A call graph is not an end in itself; it is the substrate for every question that crosses a function boundary. [[inlining]] needs to know there is exactly one callee before it can inline. [[devirtualization]] is literally the act of narrowing a callee set to one and replacing the dispatch with a direct call. Dead-code elimination at the whole-program level — tree shaking, --gc-sections, dead-method removal — needs reachability from the roots. [[interprocedural-analysis]] needs the graph to know which summaries to apply where. Even build systems use a coarse version of it.
The consequence of an *unsound* call graph is therefore not a missed optimization but a broken program. If reflection, a dynamically registered handler, a JNI callback or a dlsym lookup can reach a function the graph does not connect, whole-program dead-code elimination will delete it and the program will fail at runtime with a missing symbol or a null method reference. This is the single most common way an aggressive tree-shaking or native-image build breaks: not a bug in the analysis, but an entry point the analysis had no way to see. That is why every such toolchain ships an escape hatch — reflection configuration files, @Keep annotations, KEEP sections in linker scripts, sideEffects declarations in bundlers.
The reverse consequence — an over-approximate graph — is merely expensive: everything is reachable, nothing is dead, no call devirtualizes, and the build is large and slow. Which is the correct direction to fail in, and the reason every production call-graph builder is conservative by default and precise only where it can prove it.
- Reachability from roots: what code can run at all. Feeds tree shaking,
--gc-sections, and native-image closed-world builds. - Callee-set size at a site: 1 means direct call and inlining candidate; small means guarded polymorphic inline cache; large means real dispatch.
- Recursion and SCCs: a strongly-connected component in the graph is mutual recursion, which bounds what a summary-based analysis can do in one pass.
- Call-graph order: analysing callees before callers (reverse topological order over the SCC-condensed graph) is what makes bottom-up summaries possible.
- Escape hatches exist because the graph cannot see reflection,
eval,dlopen, FFI callbacks, or a function registered by a string name in a config file.
How it works
The steps, in the order the compiler takes them.
- Build the intraprocedural CFG: partition instructions into basic blocks at labels and after terminators, add edges for branches, fall-through, loop back edges, and every exceptional edge to an enclosing handler.
- Seed the call graph with the direct calls, which are syntactically evident, and with the program roots —
main, exported symbols, registered entry points. - For each indirect call site, compute the set of function values that may flow to the callee position, using whichever approximation the toolchain has chosen.
- Add an edge from the call site to every function in that set, which may make new code reachable and therefore new values flow.
- Iterate to a fixed point: the call graph and the value flow are computed together, because adding an edge can add a value and adding a value can add an edge.
- Condense the resulting graph into strongly-connected components to get a DAG, and process it in reverse topological order so callees are analysed before callers.
- Supply the analysis with an explicit list of the entry points it cannot see — reflection configuration, keep rules, exported symbol lists — because a sound graph is impossible without them.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A native-image or tree-shaken build passes every test and fails in production with a missing-class or missing-method error, because a handler was only ever reached by reflection from a config string.
- A bundler drops a module whose only job was a side effect on import, and a polyfill or a registration silently stops happening.
- A profiler shows a hot call site that never gets inlined, and the reason is that the callee set has two members because one obscure subclass exists in a test fixture that the analysis cannot distinguish from production code.
- A whole-program analysis runs for hours and reports nothing, because one
evalor one function-pointer table forced the callee set at a central dispatch site to "every function", and the graph became complete. - Coverage tooling reports a function as never executed while the same function appears in production stack traces, because the instrumentation used a call graph that missed the dynamic path.
- A
finallyblock's cleanup is reported as dead code by a tool whose CFG has no exceptional edges, and someone deletes it.
When it helps
- Any whole-program optimization: inlining across modules, devirtualization, link-time dead-code elimination, tree shaking a JavaScript bundle, or a closed-world native image.
- Security review: knowing which paths can reach a sink is a reachability question over the call graph, and it is the difference between "this vulnerable function exists in a dependency" and "this vulnerable function is callable from our code".
- Change-impact analysis and test selection: which tests can reach the code I changed is a reverse reachability query, and it is how large monorepos avoid running everything.
- Understanding an unfamiliar codebase — a call graph rendered from the real binary shows the paths that exist rather than the ones the documentation describes.
When it hurts
- In dynamic languages with pervasive metaprogramming, where the sound answer at most indirect call sites is "anything" and the analysis costs a great deal to produce no information.
- In plugin architectures and anywhere modules load at runtime, where the closed-world assumption the analysis needs is simply false and must be replaced by explicit declarations.
- When the precision needed is per-call-context and the code is built out of shared combinators. This is the worst case for 0-CFA and the exact place where the cost of higher k becomes unaffordable.
- As a merge-gate check: whole-program CFA is a link-time or nightly cost, not something to run per keystroke — a language server uses a far coarser and deliberately unsound approximation for "find implementations".
What it costs
Every one of these is paid by something.
- Higher context sensitivity (
k) buys separation of shared helpers and pays analysis time and memory that grows exponentially ink, which is why almost nothing ships past k = 1. - A sound over-approximate call graph buys the right to delete unreachable code and pays in a graph so dense that little is provably unreachable, so the optimization it enabled does not fire.
- An unsound but precise graph — assume no reflection, assume a closed world — buys aggressive optimization and pays with runtime failures that only appear on the code path nobody tested.
- Inline caching buys per-site precision at runtime for free from the analysis's point of view, and pays a guard on every call, memory per call site, and the substantial engineering of a deoptimization mechanism.
- Whole-program analysis buys cross-module precision and pays the ability to compile modules independently — see
[[separate-compilation]]— which shows up as a build that cannot be incremental at the link step.
What else you could do
What a different compiler or language does instead, and when that is better.
- Let the type system bound the callee set: sealed hierarchies,
finalmethods and non-virtual-by-default make CHA precise for free, which is a language-design answer rather than an analysis one. - Observe instead of prove: a JIT's inline cache, or a profile-guided build, records the callees that actually occur and speculates, with a guard and a fallback. More precise than static analysis and requires a deoptimization path.
- Declare the graph: reflection configuration,
@Keep/@Reflectiveannotations, explicit entry-point lists and bundlersideEffectsfields let the human supply what the analysis cannot see. Tedious, and the only sound option in an open world. - Trace-based approaches that record real execution and build the graph from it — precise for what ran, silent about the rest, and the standard technique for generating native-image reflection configuration.
- Skip the graph entirely and analyse each function in isolation with worst-case assumptions at every call. Fast, always sound, and imprecise enough that most interprocedural questions become unanswerable.
See it for yourself
The flag, dump or tool that shows you this directly.
- LLVM:
opt -passes=print-callgraph -disable-output file.llprints the call graph; pass names move between releases, so checkopt --print-passes | grep -i callgraphon your build first. - GCC:
gcc -O2 -fdump-ipa-cgraph -c file.cwrites a.cgraphdump listing nodes, callers and callees;-fdump-ipa-allgives the whole interprocedural set. - Java:
jdeps --print-module-depsfor the coarse version; for a real call graph, Soot, WALA or SootUp, all of which let you choose CHA, RTA or k-CFA and see the difference in edge count. - GraalVM native image:
-H:+PrintAnalysisCallTreewrites the reachability tree the closed-world analysis computed, which is the fastest way to find out why a class was kept — or why it was not. - JavaScript bundlers:
webpack --profile --jsonorrollup --plugin visualizershow what survived tree shaking;esbuild --analyzeprints the reachability decision per module. - Go:
go build -gcflags=-mreports inlining and escape decisions, which is the observable consequence of its (deliberately simple) call analysis;golang.org/x/tools/cmd/callgraphbuilds real graphs with a choice of algorithm. - Binaries, after the fact:
objdump -dplus a cross-reference tool, or Ghidra/radare2, which reconstruct a call graph from machine code and hit exactly the same indirect-call problem with less information.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Control-flow analysis means analysing if-statements and loops." That is the intraprocedural half and it is the easy half. The term of art means resolving which functions a call site can reach.
- "The compiler knows what my function calls." For direct calls, yes. For a call through a variable, an interface or a function pointer, it knows a *set*, and the size of that set is what determines whether anything downstream can be optimized.
- "Tree shaking removes code that is not used." It removes code that is not *reachable in the graph it built*. Anything reached by a name looked up at runtime is invisible to that graph and needs to be declared.
- "A JIT does control-flow analysis better than a static compiler." It does something different: it observes rather than proves, so it is more precise about what happened and guarantees nothing about what could happen. That is why it needs guards.
- "If two subclasses exist, the call cannot be devirtualized." It can, if the analysis can prove only one is ever instantiated on the reachable paths — which is exactly what rapid type analysis is for.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Inside one function, working out what runs in what order is easy — it is right there in the syntax. Across functions it is not, as soon as the thing being called is a value rather than a name: a callback, a virtual method, an entry in a table. Working out which functions a call site can reach is what "control-flow analysis" means in this stricter sense, and it matters because inlining, devirtualization and dead-code removal all need the answer. When a bundler deletes code you needed, or a native image cannot find a class, that analysis missed an entry point.
practical
Two habits pay for themselves. First, when something will not inline or devirtualize, find out how many callees the toolchain thinks the site has — -fdump-ipa-cgraph, -Rpass-missed=inline, PrintAnalysisCallTree — before theorising. It is usually two, and usually one of them is a test double or an unused subclass. Second, when a closed-world build (native image, tree shaking, --gc-sections) breaks at runtime, do not treat it as a build bug: it is a call-graph gap, and the fix is to declare the entry point the analysis could not see. Generating that declaration from a trace of a real run is the standard workflow and it is much faster than guessing.
advanced
The structural insight is that CFA and data-flow analysis are the same fixed point viewed from two sides, and the k-CFA hierarchy is the knob controlling how much of the call history each abstract binding remembers. It is also worth knowing that k-CFA past k = 1 is provably expensive — deciding it is EXPTIME-complete for the standard formulation — which is why the field largely moved to other axes: object sensitivity (context is the receiver's allocation site rather than the call site), type sensitivity, and demand-driven queries that compute a callee set only where somebody asked. The practical lesson from that literature is that the *right* context abstraction is language-shaped: object sensitivity outperforms call-site sensitivity substantially on object-oriented code and not on functional code, so the choice is a modelling decision about your programs, not a dial to turn up.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
final or LTO plus -fwhole-program-vtables to reach a comparable conclusion. Do not carry a devirtualization result between toolchains.eval string names a given function is equivalent to deciding what the program computes. This is why every closed-world toolchain defines its own sound-by-declaration escape hatch rather than trying to analyse harder: the specification of the analysis explicitly excludes what it cannot see, and requires the user to supply it.If you were asked this in an interview
- What does the term "control-flow analysis" mean when the callee is a variable, and why is it circular?
- Explain the difference between 0-CFA and 1-CFA on a shared
maphelper. - A native-image build works in tests and throws a missing-method error in production. Where do you look and why?
Connections
- Programming Languages & Runtime Internals — Virtual dispatch, vtables and method caches as they exist at runtimeThe runtime mechanism for an unresolved call — the vtable lookup, the itable, the megamorphic cache — is what remains when the analysis fails to narrow the callee set to one. The mechanism belongs there; why the analysis could not narrow it, and what that costs the optimizer, is ours.
- Testing & Reliability Engineering — Trace-driven configuration generation and change-impact test selectionBoth are reachability queries over a call graph run for a non-compiler purpose: recording a real execution to produce reflection configuration, and computing which tests can reach a change. The testing strategy is owned there; the graph and its unsoundness are ours.