Writing Your First LLVM Pass on the New PassManager (and Why the Old One Is Gone)
LLVM's new PassManager turns a custom pass into a plain C++ class with one run method. Here's a worked out-of-tree plugin example, how analysis invalidation works, and what porting costs.
03 Sept 2025, 10:43 UTC

If you've ever tried to bolt a custom optimization onto LLVM and bounced off the legacy PassManager's boilerplate — INITIALIZE_PASS macros, getAnalysisUsage overrides, registration incantations — the new PassManager (NPM) is the reason to try again. Since LLVM 13 it's the default for opt and Clang's optimization pipeline, and the legacy PM is gone from current releases. The useful takeaway: a custom pass is now an ordinary C++ class with one run method, and analysis caching and invalidation happen automatically.
The concrete problem: keeping analyses honest
Every optimization pass mutates IR, and every mutation potentially invalidates analyses other passes depend on — dominator trees, loop info, alias analysis results. The legacy PM made you declare this by hand in getAnalysisUsage. Forget to preserve an analysis you actually kept valid, and you paid for needless recomputation. Claim to preserve one you broke, and you got miscompiles that surfaced nowhere near your pass.
NPM flips this around. Your pass returns a PreservedAnalyses value describing what remains valid after it runs. The pass manager uses that return value to invalidate cached analyses precisely — no registration macros, no separate bookkeeping method, and the contract lives in one place: the end of your run method.
A pass is just a class
A minimal function pass under NPM looks like this (LLVM 17+ API):
#include "llvm/IR/PassManager.h"
#include "llvm/IR/Function.h"
using namespace llvm;
namespace {
struct CountInstrsPass : public PassInfoMixin<CountInstrsPass> {
PreservedAnalyses run(Function &F, FunctionAnalysisManager &FAM) {
unsigned Count = 0;
for (auto &BB : F)
Count += BB.size();
errs() << F.getName() << ": " << Count << " instructions\n";
// We changed nothing, so every cached analysis stays valid.
return PreservedAnalyses::all();
}
};
} // namespaceThree things to notice. First, PassInfoMixin<CountInstrsPass> is a CRTP base that supplies the glue the pass manager needs — you write no registration code. Second, the pass is parameterized on IR granularity: implement run(Function&, FunctionAnalysisManager&) and it's a function pass; swap in Module/ModuleAnalysisManager or LazyCallGraph::SCC/CGSCCAnalysisManager and it runs at module or call-graph-SCC level. Third, the return value is the invalidation contract. If your pass rewrites instructions but provably keeps the CFG intact, return PreservedAnalyses and call preserve<DominatorTreeAnalysis>() on it — the dominator tree survives for the next pass instead of being rebuilt.
Loading it without rebuilding LLVM
NPM supports out-of-tree plugins as shared libraries, so you don't need to patch LLVM's source tree. Add a plugin entry point to the same file:
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Passes/PassBuilder.h"
extern "C" LLVM_ATTRIBUTE_WEAK PassPluginLibraryInfo llvmGetPassPluginInfo() {
return {LLVM_PLUGIN_API_VERSION, "CountInstrs", LLVM_VERSION_STRING,
[](PassBuilder &PB) {
PB.registerPipelineParsingCallback(
[](StringRef Name, FunctionPassManager &FPM,
ArrayRef<PassBuilder::PipelineElement>) {
if (Name == "count-instrs") {
FPM.addPass(CountInstrsPass());
return true;
}
return false;
});
}};
}Build it against an installed LLVM (you need the LLVM development headers and a C++17-or-later compiler; matching the plugin's LLVM version to your opt binary is required — the plugin API version is checked at load time):
clang++ -fPIC -shared CountInstrs.cpp -o libCountInstrs.so \
$(llvm-config --cxxflags) $(llvm-config --ldflags)Then run it with opt from the same LLVM install, on any bitcode file:
opt -load-pass-plugin=./libCountInstrs.so \
-passes='count-instrs' -disable-output input.bcYou should see one line per function on stderr. To confirm the pass manager is doing what you think, add -debug-pass-manager (requires an assertions-enabled build of LLVM) to see each pass execution and analysis invalidation event, or -print-after-all to inspect the IR after each pass in the pipeline.
Composing pipelines from the command line
The -passes flag takes a textual pipeline, which is itself a useful debugging tool. Nesting expresses granularity: -passes='module(function(count-instrs),inline)' runs your function pass and the inliner inside a module-level wrapper. This replaces the legacy PM's implicit scheduling and makes experiments cheap — you can interleave your pass with stock passes like instcombine or mem2reg to see how they interact, without writing any scheduling code.
Trade-offs and limitations
The main cost is the port. Legacy passes with large getAnalysisUsage declarations and cross-pass dependencies don't translate mechanically; you have to re-express analysis access through the appropriate AnalysisManager (e.g., FAM.getResult<DominatorTreeAnalysis>(F)) and think honestly about what each pass preserves. That's more upfront design work, though it tends to surface invalidation bugs the old API let slide.
Two practical caveats. First, the plugin interface is version-locked: a plugin built against LLVM 17 headers won't load into an LLVM 18 opt, so distribute source or per-version binaries. Second, while essentially all in-tree passes have been on NPM for several releases, if you're maintaining a fork with legacy-only passes, you can't mix both managers in one pipeline — you'll need to port them or keep an older LLVM for that work.
Where to go next
Take an analysis you currently compute by walking IR by hand — call counts, allocation sites, hot-loop annotations — and re-express it as an NPM analysis pass (return your result type from run, register it with FunctionAnalysisManager). Then write a transform pass that consumes it via FAM.getResult and returns a precise PreservedAnalyses. That two-pass exercise is the fastest way to internalize the model, and the -debug-pass-manager output gives you an immediate, concrete check that your invalidation contract is what you intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.