Building a Custom LLVM Pass with the New Pass Manager: A Practical Guide
Step-by-step guide to writing, building, and testing a custom LLVM FunctionPass using the New Pass Manager. Covers pass structure, IR transformation with IRBuilder, plugin registration, CMake setup, and verification with opt.
22 Mar 2026, 00:10 UTC

Desired Outcome
Create a loadable LLVM plugin that implements a custom FunctionPass to analyze basic blocks, identify redundant load/store pairs, and replace them with a single register-to-register move. The pass integrates with the New Pass Manager (NPM) pipeline, runs after early optimizations, and produces verifiable IR changes observable via opt -print-after-all.
Prerequisites
- LLVM 17+ source or development package with headers and
llvm-config(tested against LLVM 17.0.6 and 18.1.0). - CMake 3.20+ and a C++17 compiler (GCC 11+, Clang 14+, MSVC 19.30+).
- Familiarity with LLVM IR hierarchy:
Module → Function → BasicBlock → Instruction. - Build directory separate from source (out-of-tree build recommended).
Pass Skeleton and Registration
Start with a minimal plugin structure. The pass inherits from PassInfoMixin and implements a run method returning PreservedAnalyses. Create RedundantLoadStorePass.h:
#ifndef REDUNDANT_LOAD_STORE_PASS_H
#define REDUNDANT_LOAD_STORE_PASS_H
#include "llvm/IR/PassManager.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Passes/PassBuilder.h"
namespace llvm {
class RedundantLoadStorePass : public PassInfoMixin {
public:
PreservedAnalyses run(Function &F, FunctionAnalysisManager &FAM);
};
} // namespace llvm
#endif
In RedundantLoadStorePass.cpp, implement the analysis and transformation. The pass iterates each basic block, tracks the most recent store to each address, and replaces a subsequent load from the same address with a bitcast or move when the stored value is still live:
#include "RedundantLoadStorePass.h"
#include "llvm/IR/IRBuilder.h"
#include "llvm/IR/Instructions.h"
#include "llvm/ADT/SmallPtrSet.h"
#include "llvm/ADT/DenseMap.h"
using namespace llvm;
PreservedAnalyses RedundantLoadStorePass::run(Function &F, FunctionAnalysisManager &) {
bool Changed = false;
IRBuilder<> Builder(F.getContext());
for (BasicBlock &BB : F) {
// Map from pointer operand to the stored value instruction
DenseMap LastStore;
for (Instruction &I : BB) {
if (auto *Store = dyn_cast(&I)) {
Value *Ptr = Store->getPointerOperand();
Value *Val = Store->getValueOperand();
// Only track stores of non-volatile, non-atomic values
if (!Store->isVolatile() && !Store->isAtomic()) {
LastStore[Ptr] = cast(Val);
}
} else if (auto *Load = dyn_cast(&I)) {
Value *Ptr = Load->getPointerOperand();
if (!Load->isVolatile() && !Load->isAtomic()) {
auto It = LastStore.find(Ptr);
if (It != LastStore.end()) {
Instruction *StoredVal = It->second;
// Ensure stored value dominates the load (same BB, earlier)
if (StoredVal->getParent() == &BB) {
Builder.SetInsertPoint(Load);
Value *Replacement = StoredVal;
// Handle type mismatch via bitcast if needed
if (Replacement->getType() != Load->getType()) {
Replacement = Builder.CreateBitCast(StoredVal, Load->getType());
}
Load->replaceAllUsesWith(Replacement);
Load->eraseFromParent();
Changed = true;
}
}
}
}
// Invalidate tracked stores if instruction may write memory
if (I.mayWriteToMemory()) {
for (auto &Use : I.operands()) {
LastStore.erase(Use.get());
}
}
}
}
if (!Changed) return PreservedAnalyses::all();
// CFG unchanged, but instructions modified
PreservedAnalyses PA;
PA.preserveSet();
return PA;
}
Plugin Entry Point
Register the pass with the PassBuilder so it can be added to pipelines by name. Add this at the bottom of RedundantLoadStorePass.cpp:
extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() {
return {LLVM_PLUGIN_API_VERSION, "RedundantLoadStorePass", LLVM_VERSION_STRING,
[](PassBuilder &PB) {
PB.registerPipelineParsingCallback(
[](StringRef Name, FunctionPassManager &FPM,
ArrayRef) {
if (Name == "redundant-load-store") {
FPM.addPass(RedundantLoadStorePass());
return true;
}
return false;
});
}};
}
CMake Build Configuration
Create a CMakeLists.txt that builds a shared library plugin. This example assumes an LLVM installation prefix; adjust LLVM_DIR if using a source build:
cmake_minimum_required(VERSION 3.20)
project(RedundantLoadStorePass LANGUAGES CXX)
find_package(LLVM REQUIRED CONFIG)
message(STATUS "Found LLVM " ${LLVM_PACKAGE_VERSION})
message(STATUS "Using LLVMConfig.cmake in: " ${LLVM_DIR})
# Use LLVM's CMake helpers for consistent flags
include(${LLVM_CMAKE_DIR}/LLVMConfig.cmake)
add_library(RedundantLoadStorePass MODULE
RedundantLoadStorePass.cpp
)
target_link_libraries(RedundantLoadStorePass PRIVATE LLVM::Support LLVM::IR LLVM::Passes)
# Ensure plugin gets correct suffix (.so/.dylib/.dll)
set_target_properties(RedundantLoadStorePass PROPERTIES
PREFIX ""
SUFFIX "${LLVM_PLUGIN_SUFFIX}"
)
Building and Loading the Plugin
Run the following commands from a clean build directory. Replace /path/to/llvm/install with your LLVM prefix (where llvm-config lives):
mkdir build && cd build
cmake -DLLVM_DIR=/path/to/llvm/install/lib/cmake/llvm ..
cmake --build . --config Release
The output libRedundantLoadStorePass.so (or .dylib/.dll) is now loadable by opt.
Testing the Pass
Create a test file test.ll with a redundant load/store pattern:
; test.ll
define i32 @foo(i32* %ptr) {
entry:
store i32 42, i32* %ptr
%val = load i32, i32* %ptr
ret i32 %val
}
Run the pass through opt with the plugin loaded and the pass name in the pipeline:
opt -load-pass-plugin ./libRedundantLoadStorePass.so \
-passes=redundant-load-store \
-S test.ll -o test.opt.ll
Expected output test.opt.ll should show the load replaced by the constant:
define i32 @foo(i32* %ptr) {
entry:
store i32 42, i32* %ptr
ret i32 42
}
Verification and Debugging
Use -print-after-all to see IR after each pass in the pipeline, confirming your pass runs and produces the expected change:
opt -load-pass-plugin ./libRedundantLoadStorePass.so \
-passes=redundant-load-store \
-print-after-all test.ll -disable-output 2>&1 | head -60
For a full pipeline context, insert your pass after early optimizations (e.g., after instcombine):
opt -load-pass-plugin ./libRedundantLoadStorePass.so \
-passes='default,redundant-load-store' \
-S test.ll -o test.full.ll
Verify the final bitcode with llvm-dis if you output binary bitcode (-f instead of -S).
Common Pitfalls and Recovery
- Mixing Legacy and New PM: Do not use
-load(legacy) with-load-pass-plugin(NPM). The legacy manager ignores NPM passes and vice versa. Use only-load-pass-pluginand-passes=. - Invalidating analyses incorrectly: Return
PreservedAnalyses::all()when no changes; otherwise preserve onlyCFGAnalysesif you modify instructions but not control flow. Over-preserving leads to stale analyses; under-preserving forces recomputation. - Use-after-free on erased instructions: Never hold raw
Instruction*across an erase. The example above usesreplaceAllUsesWithbeforeeraseFromParent, and theLastStoremap stores the stored value, not the store instruction itself. - PHI node corruption: This pass does not modify the CFG. If you later extend it to split blocks or insert branches, update PHI nodes in successor blocks via
PHINode::addIncomingor useSplitBlockutilities. - Plugin version mismatch: The
LLVM_PLUGIN_API_VERSIONmust match the loadingopt. Rebuild the plugin against the exact LLVM version used at runtime.
Limitations and Extensions
The example pass is intra-block only and does not handle aliasing (two different pointers referencing the same address). For production use, consider integrating AAResults from FunctionAnalysisManager to query alias analysis. Request it in run via FAM.getResult(F) and check alias(Ptr1, Ptr2) before replacing loads.
To run automatically in Clang, add the pass to the optimization pipeline via a PassBuilder::PipelineParsingCallback in a Clang plugin, or patch PassBuilder::registerOptimizerLastEPExtension in a forked LLVM build.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.