cl::opt globals are how LLVM’s libraries declare their internal developer options (2800+ in 70 components of llvm/lib; 1749 are cl::opt<bool>).
Each one is a constructor run at startup that registers into a StringMap, ~200 bytes of .bss and a couple of load-time relocations; the value is process-wide, so two LLVMContexts in one process cannot differ (rustc’s -Cllvm-args is silently dropped after the first session for this reason); and a library reading another’s option writes extern cl::opt<bool> X; (147 in tree, 48 across libraries) or exports one from a public header.
RFC: replacement CLI library for LLVM and tools proposes a new library, CommandLineV2, to fix this.
This proposal fixes it with what we already have: llvm/Option’s TableGen representation (recently shrunk from 88 bytes to 16 bytes and zero dynamic relocations per option, #224374 through #227582, thanks to @aengelke for all the reviews) for the declarations, and a generated struct per library for the values. A context (LLVMContext for the IR-level libraries, MCContext for the MC layer) holding per-context copies is a later step.
cl:: keeps owning command line parsing until the migration completes, so option spellings do not change for tools.
Status: the cl:: cleanup (step 1 below) has landed, and so has the framework (#226087, with LLVMCGData’s 13 options). InstCombine (#227948) and Sparc (#227977) followed, chosen to stress the framework: a pass library with a central object, and a target whose options reach code through the TargetMachine and Subtarget. X86 and MC are next; they add std::optional, enum and tri-state fields.
Declaring
A library’s options live in a .td in its own tree:
include "llvm/Option/LibraryOptions.td"
def CGDataOptions : OptionsStruct;
defm : BoolField<"codegen-data-generate", "false",
"Emit CodeGen Data into custom sections">;
defm : ValueField<"codegen-data-use-path", "StringRef", "",
"File path to where .cgdata file is read">;
defm : ValueField<"global-merging-max-params", "unsigned",
"std::numeric_limits<unsigned>::max()",
"The maximum number of parameters allowed when merging functions.">;
llvm-tblgen -gen-opt-parser-defs emits struct CGDataOptions { bool codegen_data_generate{0}; StringRef codegen_data_use_path{}; ... }, a Global instance, the option table, and apply(const Arg &).
A member is named after its option in snake_case, matching OptTable’s OPT_codegen_data_generate; defm Name : ... names it explicitly. A default is the member’s C++ initializer.
OptionsStruct<"mlir"> places the struct in another namespace, and OptionsStruct<prefix = "sparc-"> drops a shared prefix from member names, so -sparc-bpcc-offset-bits sets bpcc_offset_bits while the spelling keeps its prefix.
The rest of a home is fixed boilerplate, the same for every library; this is all of LLVMCGData’s, license banners removed:
// lib/CGData/CGDataOptions.h
#include <limits>
#define OPTIONS_STRUCT_DECL
#include "CGDataOptions.inc"
// lib/CGData/CGDataOptions.cpp
#include "CGDataOptions.h"
#include "llvm/Option/LibraryOptions.h"
#define OPTIONS_STRUCT_DEFS
#include "CGDataOptions.inc"
static llvm::opt::RegisterLibraryOptions<llvm::CGDataOptions> Registration;
A target adds no files besides its .td: XXXSubtarget.h includes the declaration block, XXXSubtarget.cpp the definitions, and LLVMInitializeXXXTarget() registers with a function-local static opt::RegisterLibraryOptions<XXXOptions> O;, so a target’s options cost no static initializer and exist only once the target is initialized. llc, opt, clang -cc1 and lld initialize targets before parsing -mllvm (lld/ELF needed a one-line reorder).
# lib/CGData/CMakeLists.txt
set(LLVM_TARGET_DEFINITIONS CGDataOptions.td)
tablegen(LLVM CGDataOptions.inc -gen-opt-parser-defs)
add_public_tablegen_target(CGDataOptionsTableGen)
# and DEPENDS CGDataOptionsTableGen, LINK_COMPONENTS Option on the library
Adding an option is one defm.
A bool is -x or -x=true|false|1|0, and a value is -x=v or -x v, exactly what cl::opt accepts, so no test or script changes spelling when a library migrates. --x works for -x.
Library options are developer knobs, so only -help-hidden lists them (2,589 of llvm/lib’s 2,939 cl::opts are already cl::Hidden). Options users should see belong in a tool’s OptTable, including the shared codegen flags such as -mtriple (CodeGen/CommandFlags.cpp), which become a .td fragment that tools include rather than a library struct.
Members may be bool, integers, float, double, or StringRef. StringRef rather than std::string keeps Global constant-initialized: no constructor, no destructor, no allocation for a non-empty default. Each further kind arrives with the first library that needs it:
std::optional<T>defaulting tostd::nulloptreplacesgetNumOccurrences()(X86).OptionalBoolField, astd::optional<bool>, replacescl::boolOrDefault;EnumFieldreplacescl::valuesand shows its values as the metavar,-dwarf-extended-loc=<Default|Enable|Disable>(MC).DefaultOnOffFieldis anEnumFieldofstd::optional<bool>spelledDefault|Enable|Disable; it replaces the file-localDefaultOnOffenums in MC and DwarfDebug, so no library has to export one.- Two new OptTable kinds,
FlagOrEq(-x,-x=v) andSeparateOrEq(-x=v,-x v), make every field one table row instead of aJoinedtwin plus aFlagor aSeparatealias; CGDataOptions.cpp.o shrinks from 4,977 to 4,370 bytes. lld/ELF’sEq/EEqoptions have the same shape and drop 68 of 435 rows with them.
Lists come with the first library that needs one.
Reading
CGDataOptions::Global.codegen_data_generate: one load, the same as acl::optread. This is the mechanical replacement for astatic cl::opt, and what is left when a library has no owning object.- Where an owner exists, it binds
Globalonce and readers go through it.SparcTargetMachineholdsconst SparcOptions &CLOpts,SparcSubtargetcopies the reference, and code readsSubtarget.getCLOpts().bpcc_offset_bits; InstCombine’sInstCombinerImpldoes the same. Only the owner’s constructor namesGlobal, sogrep '::Global'is the list of sites a context has to reach.
A home lives in lib/X/ and is private, like the static cl::opts it replaces: another library that needs a knob calls a getter or takes a parameter (LTO calls cgdata::thinLTOTwoRounds(); llvm-cgdata passes lazy loading to CodeGenDataReader::create, #226861). A library can expose its struct under include/ when there is a real user, such as an embedder setting a value without building argv strings.
Toward per-context options
RegisterLibraryOptions does two jobs that separate cleanly. Registering the option table is immutable: it lets cl:: recognize, validate and list -mllvm -sparc-..., and it can stay global. Storing the values in T::Global is the global state. apply(const Arg &) is an instance method, so any owner can hold its own copy.
The path, in order:
- Readers go through an owner. Done in each port; what still names
Globalis the inventory. opt::parseLibraryOptions<T>(Args, Base)builds an instance from a base and an argument list, usingT’s table and ignoring other libraries’ arguments.- The per-compilation arguments that tools already keep (clang’s
FrontendOpts.LLVMArgs,lto::Config::MllvmArgs) reach the owner through its options:TargetOptionsfor a TargetMachine,MCTargetOptionsfor MC,LLVMContextor the pass builder for IR passes. The owner’s instance isGlobalwith those arguments applied on top, so-mllvmon the process command line keeps working. For Sparc the switch is one line:CLOpts(getLibraryOptions<SparcOptions>())in place ofCLOpts(SparcOptions::Global). - A tool stops applying a library’s options globally once it hands them to the context; validation still goes through the registry, so unknown options are rejected once, up front.
Globalgoes when nothing reads it, andRegisterLibraryOptionsshrinks to table registration.
Steps 1–3 can land today as a handful of small patches, but they change nothing visible: while tools still parse -mllvm globally, every context’s copy equals Global. What blocks step 4 is outside the ports: the -mllvm list also carries ~2,800 unported cl::opts, so tools keep calling cl::ParseCommandLineOptions; embedders (LLVMParseCommandLineOptions, rustc, Swift, Julia) set options process-wide and need Global as a fallback; and a library needs an owner that reaches its readers (targets and MC have one, IR passes need plumbing, CGData has none). So the plumbing waits for its first user — clangd or libclang reusing -cc1 in process, threaded ThinLTO backends with different settings, a JIT — and the ports keep the rule that makes it a one-line switch.
cl:: integration
RegisterLibraryOptions<T> hands cl:: the option names, a parser, help and reset.
ParseCommandLineOptions indexes the names at its first parse and dispatches an argument that names none of cl::'s options to the library that declares it, so --, subcommands and positional arguments keep cl::'s rules. The index is rebuilt lazily when a library registers during parsing. A name shared with a cl::opt or another library is a fatal error; options of unregistered subcommands (MLIR’s PassOptions) are not affected.
cl:: copies each library argument into an allocator that ResetAllOptionOccurrences resets, so StringRef members outlive response files. -help-hidden (under “Library options:”) and ResetAllOptionOccurrences cover library options; -print-options, “Did you mean” suggestions and cl::OptionCategory do not yet, so cl::HideUnrelatedOptions cannot hide them.
Cost
Measured on the prototype, which also migrated LLVMPasses: PassesOptions.cpp.o for 33 options was 13.6 KB of text (a 98-row, 24-byte table including the since-dropped -no-x rows, 3.9 KB of strings, the apply switch) and a 195-byte static initializer, against −6.6 KB of static-initialization code, −7.2 KB of .bss and −52 relocations for the 33 cl::opts it replaced. Reads compile to loads; the tracker run shows CTMark within noise (geomean ±0.01% instructions, ±0.26% max-rss) and the clang binary at −1.5% .bss, +0.01% text.
Migrating a library
Net line count is the honest metric for a migration. On the prototype, next to the CommandLineV2 branch’s migration of the same libraries:
| options | prototype | CommandLineV2 branch | |
|---|---|---|---|
| LLVMPasses | 33 (50 there, incl. StandardInstrumentations) | 8 files, +246 −229 = +17 | 18 files, +1563 −496 = +1067 |
| of which PassBuilderPipelines.cpp | +68 −224 = −156 | +263 −233 = +30 | |
| LLVMCGData, LTO’s read, llvm-cgdata | 13 (12 there) | 11 files, +149 −101 = +48 | 16 files, +481 −123 = +358 |
| per option | +0.5 (Passes), +3.7 (CGData) | +21, +30 |
Where the lines go here: the .td (the old cl::opt declarations with their help text, 2–3 lines each), a small header and .cpp that are mostly license, and a few build lines; the read sites shrink (static cl::opt<bool> X(...) + X becomes Opts.x). On the CommandLineV2 branch each read site gains a static bool getX(const OptionsContext &) wrapper and its callers gain a context parameter.
Extrapolated to the 2,806 options in llvm/lib at +0.5 to +4 net lines each, the whole migration is a few thousand lines, done one library at a time by its owners; the CommandLineV2 branch is +72k net for 3,547 options.
Relation to CommandLineV2
Both keep per-library .td files and aim for per-context values. The differences:
- No new parser or library: OptTable already exists, is used by clang, lld and most tools, and its per-option cost was just cut from 88 to 16 bytes.
- A read is a field load; CommandLineV2 reads through an open-addressed hash on the registry address (
OptionsContext::findData) plus an assert-build check, which the tracker measures at +0.34% instructions stage1-O3, +0.90% ReleaseThinLTO, +0.80% ReleaseLTO-g. - Per-context is incremental, not mandatory. CommandLineV2’s
defaultOptionsContext()is empty, so an unthreaded read gets compile-time defaults and ignores the command line; the branch has 93 such reads and a mandatoryLLVMContext(const OptionsContext &). HereGlobalis the parsed instance. - Per-option cost in the
.td: 2–3 lines against 11; a home is 3 files against 7; a read isOpts.xagainst a hand-written getter per option.
Plan
- Remove the cl:: parser features no library option uses, so the two parsers agree. Landed:
cl::Sink(#224953),cl::multi_val(#224959),cl::opt<bool>accepting onlytrue|false|1|0(#224975),cl::DefaultOption(#225609),cl::Grouping(#224958, afterllvm-cov gcovmoved to OptTable in #224955),LongOptionsUseDoubleDash(#226347, after yaml2obj #224964, obj2yaml #224965 and split-file #224966 moved to OptTable),cl::bits(#226628), and BOLT’scl::callbacks (#226359).-no-<name>for bools (#224978) was withdrawn. - Land the framework with LLVMCGData as the first library (#226087).
- Libraries migrate on their own schedule, one
.tdeach; the recipe is in CommandLine.md. Landed: CGData, InstCombine, Sparc. Next: the one-row option kinds, X86, MC, then LLVMPasses. Option renames (dropping a redundant prefix) are separate, later patches, since they break scripts. Cross-library writers (DirectX’sDXILWriterPassassigning MC’s-dx-*) staycl::optuntil the writer takes a parameter. The remainingcl::callbackusers move first; pass plugins get their arguments through-plugin-arg=<plugin>,<arg>and are loaded after parsing (#226773). - Per-context copies, as above, starting where there is a user (rustc’s per-session
-Cllvm-argsneeds LLVMPasses). - Tools keep moving to OptTable one at a time, as they already do. When no
cl::optis left,cl::ParseCommandLineOptionsis a loop over the registered structs and cl:: can go.
MLIR
MLIR has no file-scope cl::opt in its libraries; its uses fall into five patterns:
- Option groups registered on demand (MLIRContext, AsmPrinter, PassManager, Timing, DebugCounter): each becomes an OptionsStruct registered from a function-local static in
registerXCLOptions(), so MLIR’s-Werror=global-constructorsholds. TheManagedStaticand everyisConstructed()check go. A ported MLIRContextOptions reads--mlir-disable-threadingwith one compare instead of three loads, two null tests and a compare, and the MLIRContext constructor goes from 355 to 298 instructions. These need enum, list andstd::optionalfields. - Options declared inside translation registrations (Cpp, LLVMIR, SPIRV, SMTLIB) port the same way; all are bool or string.
- Tool mains (MlirOptMain and the other
*Mainfunctions) belong to the tool phase; downstream tools call them and add their owncl::opts.--split-input-file’s implied marker is expressible as a Flag with aFieldValue. - Registry-driven options (each registered pass as
--<pass>, and translations likewise) stay on cl:: for now;cl::LibraryOptionsis a runtime interface that the pass registry could implement directly. - Pass options, parsed per instance from pipeline strings with
cl::optobjects in private subcommands, are out of scope. They are already declared in TableGen (Passes.td), so mlir-tblgen could generate plain members and a parser for them, decoupling pipeline syntax from cl::.
Q&A
Multiple instances per process? Not yet: Global is process-wide, as cl::opt is. Readers already go through an owner, so the per-context step (above) switches one constructor per library. The known users (rustc’s per-session -Cllvm-args, JIT embedders) need this in LLVMPasses, not everywhere.
Tool-level flag registries? CodeGen/CommandFlags.cpp and MC/MCTargetOptionsCommandFlags.cpp are opt-in registrations that tools copy into TargetOptions/MCTargetOptions; they are already per-instance in shape and are not library structs. Their declarations can move to a .td that tools include, with std::optional for the getExplicit* accessors; that is a separate design.
Which contexts? Of llvm/lib’s 2,806 options, 2,644 are read with an LLVMContext at hand (IR, Analysis, Transforms, Passes, LTO, CodeGen, the targets’ codegen halves); ~100 are the MC layer (lib/MC, the targets’ MCTargetDesc/AsmParser/Disassembler) and belong to MCContext, which already holds MCTargetOptions; ~65 (Support, ProfileData, TableGen, CGData) have no context and read Global. Polly’s 156 are LLVMContext; BOLT’s 362 would be BinaryContext.
Plugins? A plugin’s struct registers when the DSO loads, as its cl::opts do, and cl:: reindexes a library registered during parsing. #226773 changes opt and llc to load plugins after parsing and pass them arguments with -plugin-arg=<plugin>,<arg>, so a plugin’s options no longer share the process’s namespace.
Support’s own cl::opts? Every tblgen links Support, so Support and lib/TableGen cannot use generated code. Their 25 options (-help, -debug-only, -stats, -debug-counter, tblgen’s -o/-I/-d, …) are tool-level and stay cl::opt for now. When they move, the existing bootstrap covers it: llvm-min-tblgen already generates for LLVMTableGenCommon, which llvm-tblgen links; so the OptParser emitter moves into TableGen/Basic, Support’s state-setting options (-debug-only, -stats, -debug-counter, the timer knobs) are declared in a library above Support that llvm-tblgen links, and llvm-min-tblgen’s own eight driver options get a minimal parser.
Why keep cl:: for now? Because it owns argv in every tool and in -mllvm, keeping it is what makes the migration incremental: a library moves when its owners have time, every tool works throughout, and nothing is flag-dayed. cl:: shrinks as libraries and tools leave it; the end state has no cl::opt.
Disclosure: the prototype, the measurements and this text were produced with Claude Code; the design decisions are mine. @aengelke’s comments and previous RFCs including [RFC] Replacement CLI library for LLVM and tools are in the context.