063. Configurable Optimization Pass Pipeline
- Status: Accepted
- Author: @ornew
- Date: 2026-10-25
Summary
The passes over Core IR are called by two hand-written sequences, and the generator decides which run through scattered booleans. This design makes a pass a declared unit and the sequence a pipeline that a small manager runs:
- each pass declares its name, the analyses it requires and invalidates, its mode applicability and its obligations;
- a flag per optimization turns a pass or one of its variants off;
- an optional verifier runs between passes;
- named flag sets (generation profiles
speed,memoryandsize) select a coherent set of optimizations; - the module can be printed after any pass.
The default pipeline reproduces today's generated output byte for byte. Users select a profile with pego gen -optimize, and per-optimization flags and the IR dump are available as options for development and advanced use.
Motivation
The passes run as follows today, and this is the inventory the proposal starts from.
- The sequence is written twice.
Generator.TypedCore(internal/gen/gogen/typed_core.go) runsTerminals,RewriteTerms,Analyze,Guards,Scans,ClassRuns,Analyze,Verify.gen.Generator.StartRecFlow(internal/gen/generator.go) runs the same minusTerminalsandClassRuns. The order matters and is nowhere declared:Guardsreads what the firstAnalyzecomputes, and a missingAnalyzeleaves choices unguarded without an error (StartRecFlowcarries a comment about 84,000 calls on the DuckDB grammar that the wrong order caused). - Applicability is derived in several places:
lean(no work controls and no Documents),leanTyped,observabilityand the privatedisable*fields ofGenOptions. A pass such asClassRunsis sound only in lean builds, because work controls and Documents charge for reading; that obligation is encoded by anif, not stated. - Only some optimizations have a switch (
disableTerminalPrograms,disableGuardedClasses,disableLiteralTextDispatch,boxedComparisons).Guards,ScansandClassRunshave no switch, so no test checks that generation without them equals the engine. Verifyruns once, after the last pass; a pass that breaks an invariant is reported at the end, with no pass named.flow.Verifyruns only in tests.- The only debugging aid is the test hook
decisions.coreHook, which is called after each pass in the typed route. There is no way to print the module after a pass from a command. - Generation profiles do not exist. The size and speed of generated parsers are decided by one fixed set of optimizations (record 062 lists the measured mechanisms); a user who wants a smaller parser has no lever except removing features.
Record 062 requires every optimization to be a pass with a stated precondition, a private same-source switch and a negative-control test. This record gives that rule a mechanism.
Goals
- One declaration per pass: name, requirements, invalidations, applicability, obligations and the flag that controls it. Both call sites run pipelines built from those declarations.
- The default pipeline produces the same Core IR, the same Flow IR and the same generated source as today for every grammar and generator mode.
- Every optimization pass has a flag, and a test that a build without it equals the engine.
- A pass that breaks a Core IR invariant is named, when verification between passes is on.
- The Core IR can be printed after any pass.
- A profile is a named flag set;
speedis today's behavior.
Non-Goals
- Passes over Flow IR. Flow IR has no optimization passes today (lowering makes the choices; see Flow IR). The manager is written for Core IR and can host Flow passes when they exist.
- The textual pass that rewrites generated Go (
QualifyRuntime). It stays a text pass: record 062 moves the other rewrites into the printers and leaves the cost of this one to an independent fix. - The closure backend, bytecode and TypeScript, which do not use Core IR today.
- Changes to what a parser does. Flags and profiles change generated code, never results, errors or events.
- A textual pipeline language such as LLVM's
-passes=.
Design
Pass Declarations
A pass is a value with the following fields. The names are those of the pipeline code, not of any public API.
| Field | Meaning |
|---|---|
Name |
Stable identifier, also the flag name and the print-after name |
Requires |
Analyses that must be valid when the pass starts |
Invalidates |
Analyses that the pass makes stale |
After |
Passes that must precede it when both are enabled (an ordering constraint with no data dependence) |
Applies |
A predicate over the generation mode (work controls, Documents, observability, table) |
Obligation |
One sentence stating what the pass preserves, as a comment and in the test name |
Run |
The function over *core.Module |
Analyses are the data that passes read and that other passes make stale. The manager knows two:
call-graph (core.CallGraph) and attributes (core.Analyze: Nullable, First, Eff). The attributes that the
builder sets (Want, Shape) are inputs, not analyses.
The five existing passes, declared:
| Pass | Requires | Invalidates | After | Applies when (today) | Obligation |
|---|---|---|---|---|---|
terminals |
attributes |
Typed table, lean, no observability | Same input consumed and expectations recorded as the expression it replaces | ||
term-rewrites |
Always | Same results and errors, in the same order | |||
guards |
attributes |
terminals |
Always; guarded classes only when lean | A guard equals the alternative's first terminal | |
scans |
attributes |
Always | Same input consumed and expectations recorded | ||
class-runs |
attributes |
attributes |
guards |
Typed table, lean | Same input consumed and expectations recorded; the first class of a guarded alternative is left alone |
Analyze is not a pass in this model but the way the manager makes attributes valid. It is deterministic over an
unchanged tree, so how often it runs never changes the output.
The Manager
Run(m, pipeline, options) runs the enabled passes in order. Before each pass it makes the pass's required analyses
valid, recomputing the stale ones. After a pass it marks the invalidated ones stale. It fails with the pass name if a
pass lists a requirement the manager cannot compute, if two enabled passes violate an After order, or if verification
fails.
Applied to the typed table with every optimization enabled, the manager performs the same calls as TypedCore today:
attributes is stale after terminals and is recomputed before guards; it is stale again after scans and
class-runs and is recomputed once before the final verification. The recognizer pipeline is the same list without
terminals and class-runs, and Scan origins are followed as build.FollowRepeats does.
The manager lives in internal/ir/core next to the passes and depends on nothing of the engine. The mode
predicate is a value the generator passes in (Mode), so core keeps no knowledge of work controls or Documents.
Flags
Each optimization has a flag with the pass name. Variants of one pass have their own flags.
| Flag | Controls | Replaces today |
|---|---|---|
terminals |
terminals |
disableTerminalPrograms |
term-rewrites |
term-rewrites |
|
text-dispatch |
TextDispatch of term-rewrites |
disableLiteralTextDispatch |
field-compare |
FieldCompare of term-rewrites |
boxedComparisons |
guards |
guards |
none |
guarded-classes |
The classes argument of guards, and flow.Options.GuardedClasses |
disableGuardedClasses |
scans |
scans |
none |
class-runs |
class-runs |
none |
class-tables |
flow.Options.ClassTables |
the leanTyped condition |
inline-literals |
flow.Options.InlineLiterals (a count, 0 turns it off) |
the constant gen.MaxInlineLiteral |
A flag can only turn an optimization off. Turning it on never overrides Applies: class-runs stays off in a build
with work controls whatever the flag says, because the pass is unsound there. The mode facts of flow.Options (Work,
Recovers, Observe) are not optimizations and have no flag.
guards off implies guarded-classes off and makes class-runs convert every class run, which After allows.
Verification Between Passes
With verification on, the manager makes attributes valid and calls core.Verify after every pass and names the
pass that produced the first violation. Off (the default), it verifies once at the end, as today. Verification
between passes can run more analyses than the default pipeline; the output does not change.
Profiles
A profile is a named set of flags. speed is the default and sets every flag on, which is today's behavior and
therefore changes no generated output.
| Flag | speed |
memory |
size |
|---|---|---|---|
terminals |
on | on | open |
term-rewrites, text-dispatch, field-compare |
on | on | on |
guards, guarded-classes |
on | on | open |
scans |
on | on | on |
class-runs |
on | on | open |
class-tables |
on | open | off |
inline-literals |
4 | open | 0 |
The memory and size columns are hypotheses. A table is data and a straight-line expression is code, and the first
may be smaller or larger depending on the grammar; guards add code and remove calls. None of these trade-offs is
measured for this record. Each cell marked open is filled by measuring generated source size, binary size and resident
memory on the checked-in parsers, and the measured result goes in the profile's table and in
docs/optimizations/. The controls that today change run-time memory without being passes (disableTypedObjectSlabs,
disableTypedListSlabs, disableTypedOutputBootstrap) are candidates for the memory profile
but are not Core passes; whether a profile also sets them is an open question.
The word "profile" already names the rule profiler (pego profile, WithProfile), so the flag that selects a profile is -optimize.
Print After Pass
The manager takes an optional Dump func(pass string, m *core.Module), called with the pass name after each pass that
ran, and with build before the first. It replaces the test-only decisions.coreHook: the tests become users of the
same hook. A command-line front end prints with core.Print(m, true), and the Flow functions of a rule with
flow.String, to a file or to standard error. The selection is a pass name or all, as in LLVM's -print-after.
Migrating the Call Sites
- Introduce the pass declarations, the manager and the two pipelines in
core, together with a test that holds the old straight-line sequences as an oracle. For every grammar of the corpus and every mode ofcoreGenModes, run the oracle and the pipeline and comparecore.Print(m, true). No call site changes. - Switch
TypedCoreandStartRecFlowto the pipelines. Gate on three checks:go generate ./parsers ./benchleaves no diff, the corpus tests of the generator pass in default andPEGO_TEST_ORDINARY=1runs, and a byte comparison of the generated sources of the corpus before and after the switch (all modes) is empty. - Replace the
disable*booleans with the flag set. Existing tests set flags instead of fields; the flag names come from the table above. - Remove the oracle with the old sequences once step 2 has landed.
Each step is its own commit and can be reverted alone.
User-Facing Surface
Three surfaces were considered.
Option A: Internal only. Flags exist for tests and diagnostics through the private GenOptions fields. pego gen
and the Go options do not change. Profiles are not offered.
Option B: Profiles. pego gen -optimize speed|memory|size and a Go option WithOptimize select a profile.
Per-pass flags stay internal.
Option C: Profiles and per-optimization flags. Option B plus pego gen -no-opt name[,name] and
WithoutOptimizations(names ...string), for reproducing a miscompilation or measuring one optimization, and
-print-after name|all (with a Go callback) for the IR dump.
Decision: C, with the profile flag named -optimize. pego gen -optimize speed|memory|size and the Go option
WithOptimize select a profile; -no-opt name[,name], WithoutOptimizations(names ...string) and -print-after name|all serve development and advanced use, such as isolating a miscompilation or measuring one optimization. A
profile is applied first and -no-opt removes optimizations from it, so -optimize size -no-opt guards is size
without guards. The name -optimize avoids the rule profiler's pego profile and WithProfile. The flags are
described in the command-line reference only; no guide explains individual optimizations, and the
specification of generated parsers does not change, because it constrains the
parser's behavior, not the generated code. Optimization names follow the pass set, which record 062 expects to
change.
Any option leaves the generated API, results, errors and events unchanged. Documentation changes are the command-line reference and the code-generation guide; the specification does not change.
Alternatives Considered
Keep the call sites as they are, and add the missing switches. Cheapest, and fixes the missing negative controls. It leaves the order undeclared and the duplication in place, so the next pass is added in two places and the wrong-order failure remains silent. Rejected as the end state, but the missing switches are the first deliverable of this design anyway.
A fixed list without analyses. A pipeline as a slice of functions, ordered by hand, with each pass calling
Analyze itself. Smaller than a manager. It spends analysis time where a pass might not need it and cannot express a
requirement, so it cannot report the silent mis-ordering that motivated this record. Deferred, not excluded: if
declared requirements prove not to be worth their code, this is the fallback.
Full LLVM-style analysis management. Cached analyses with preserved-set computation, per-function and per-module
scopes, and a textual pipeline language. The two analyses that exist (call-graph, attributes) are cheap to
recompute and deterministic, so a cache with preservation sets buys little; per-rule scopes would matter only if
passes became rule-local. Rejected for now.
Profiles chosen by the generator from the grammar. The generator could pick size or speed by grammar size.
It makes the output depend on a heuristic that users cannot predict or override. Rejected.
Flags only on the command line, with no Go option. Matches how pego gen options are read today, but the
Go API (gen.Go) is what the build tooling of other projects calls. The options mirror each other today
(-types and Types) and the new ones should too.
Testing
- Pass declarations: a test that every pass in the registry has a flag, a documented obligation and a test of its disabled form (a table-driven check over the registry fails for a pass without them).
- The oracle comparison of step 1 and the byte comparison of step 2 above.
- Disabled passes: for each flag, generate the corpus with the flag off and run the existing generated-parser
differential (
testGeneratedParsersCorpusandtestGeneratedTypesCorpus), which compares results and errors with the engine in both position units, asTestTerminalProgramDisabledParitydoes forterminals. A negative control per flag checks that turning it off changes the generated source for a grammar where the pass applies, so a flag that does nothing is caught. - Combinations: each flag off, all optional flags off, and a seeded sample of pairs, with
Verifyafter every pass. The full 2^n product is not run. - Mode applicability: a test that
class-runs,terminalsand guarded classes do not run in builds with work controls, Documents or (forterminals) observability, whatever the flags say. - Manager: a pass that requires an analysis that is stale gets it recomputed; a pass that violates
Afterfails the run naming both passes; verification names the pass that broke an invariant (using a deliberately broken test pass). - Print after pass: the dump hook is called once per pass in order, and the printed text of the default pipeline equals the text printed by the test hook today.
- Profiles:
speedoutput equals the default output byte for byte; each profile's parsers pass the existing generated-parser corpus.
None of these tests exists yet; they land with the pipeline.
Performance and Results
The default pipeline must not change generation time beyond noise: the manager adds a constant number of checks to a
sequence of passes that is linear in the grammar. Measure gen.Go{Types: true}.Generate on the DuckDB grammar before
and after step 2 with the method of record 062.
The memory and size presets are measured as described under Profiles before they are named or
documented; until then they do not ship.
Limitations and Open Questions
- What
memoryandsizecontain. Their flag sets are hypotheses until measured. Whether they also set the typed slab controls, and whethersizealso changes the runtime embedded in the parser, is open. - Granularity of
term-rewrites. Each rewrite (TextEq,ConcatFused,ProjectedMap) could have its own flag. Today no printer reads the rewritten terms, so a flag changes only the Core IR, and splitting it earns nothing until the Flow printer takes terms from Core. - Flow passes. When Flow IR gains passes, the manager runs them, and the flag and profile tables grow. Whether
Flow lowering's options (
class-tables,inline-literals) become passes is decided with them. - Observability and work controls as passes. They are modes, not optimizations, so they stay out of the flag set; generated parsers keep fixing them at generation time.
- Missing negative controls.
guards,scansandclass-runshave none today; whether a build without them equals the engine is expected but unverified, and is the first result this design needs.