Ship a default @inline directive pack for the prelude/core forks - #327
Merged
Conversation
PureScript identifiers may contain underscores and primes (Effect.Ref.modify_, a helper named go'), but the directive target parser accepted only alphanumeric characters, so no module-header pragma or --directives file entry could ever name such a binding. Extend the name parser accordingly. Part of #242: the default directive pack needs Effect.Ref.modify_.
Directive-driven inlining folds a constant chain one layer per optimize round, so legitimate fixpoint iteration counts scale with the deepest such chain in the module: the ~300-deep golden stress chains need several hundred rounds once the default directive pack unfolds their combinators. The backstop still catches a pass that over-reports changes or genuinely loops, just later. Part of #242.
Pasting a specialization's right-hand side re-evaluates its applied arguments at every use site. A user-marked call site is licensed to duplicate that work by the explicit directive, but a derived directive is compiler-initiated: deriving always-inline for a specialization over a computed argument — e.g. bind (bindExceptT monadIdentity), where the dictionary is built by applying the transformer's instance function — re-ran the construction per site, growing the ~300-deep ExceptT stress golden by 40% once the default pack put arity=1 on bind. The derivation now requires every applied argument to be a value (a reference, a literal, or a lambda); a settled dictionary literal still qualifies, so the Maybe/Either chain collapses keep firing. Part of #242, refines #241.
…core forks The compiler now loads a built-in directive pack for the prelude/core forks (Inliner.defaultDirectives): the class member accessors (map, bind, append, pure, compose, ...) at arity=1 so applying one to a dictionary resolves to the instance method and starts the inlining cascade; the tiny dictionary-parameterized combinators (bindFlipped, applySecond, composeKleisli, mapFlipped, when, ...); the generic- representation glue (genericShow/genericEq/genericCompare, to/from); identities (otherwise, flip, const); the function-instance dictionary methods (semigroupFn.append arity=2, categoryFn.identity always, semigroupoidFn.compose arity=2, functorFn.map arity=2); and the ST/Ref modify wrappers. These force-inline the ubiquitous combinators that fall below the size threshold and otherwise block downstream specialization, so common dictionary-parameterized code specializes away with no annotations in user code. The pack is the lowest-precedence directive source (resolveModes gains a fourth tier): a local module-header pragma beats the --directives file, which beats an @inline export pragma, which beats the pack, and an explicit default mode masks a pack entry back to the built-in heuristics. Pack entries are best-effort like file entries. A parse failure of the embedded pack is a compiler bug; the test suite forces the parse. Corpus effect: total golden.lua drops from 739,666 to 503,370 bytes (-32%). The ~300-deep constant chains now fold to their final values at compile time (LongMaybeBindModule 104,416 -> 56 bytes, LongApplyChain 26,216 -> 513, LongBindFlipped 24,447 -> 627); Bench.BindChain collapses to its result function, taking its per-call closure allocations (FNEW 2 -> 0), per-call table allocations (TNEW+TDUP 4 -> 0) and LuaJIT trace aborts (3 -> 0) to zero. The 300-deep transformer stress goldens grow 1-4% (computed dictionary arguments block the derived-directive paste by design); ordinary-shaped goldens shift within 2%. Eval goldens are unchanged across the corpus. The new Golden.DirectivePack golden exercises the flagship cascades end-to-end: a flipped-bind Maybe chain, Semigroup/Category on functions, otherwise in guards, genericShow through the Rep sum, and Ref.modify_ - all with no annotations in the source. Closes #242.
Unisay
marked this pull request as ready for review
July 28, 2026 09:51
This was referenced Jul 28, 2026
This was referenced Jul 28, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #242.
The compiler now ships a default
@inlinedirective pack for the prelude/core forks: a built-in set of inlining directives (Inliner.defaultDirectives, written in the--directivesfile DSL and parsed at startup) covering the tiny, ubiquitous dictionary-parameterized combinators that fall below the inliner's size threshold and block downstream specialization. With the pack, common dictionary-parameterized code specializes away with no annotations in user code.What the pack contains
Four groups, each entry motivated by a corpus-visible collapse: the class member accessors (
map,bind,append,pure,compose, ...) atarity=1, so applying one to a dictionary resolves to the instance method and starts the inlining cascade; the tiny dictionary-parameterized combinators (bindFlipped,applySecond,composeKleisli,when, ...); the generic-representation glue (genericShow/genericEq/genericCompareatarity=2,to/fromatarity=1) plus identities (otherwise always,flip,const) and the function-instance dictionary methods (semigroupFn.append arity=2,categoryFn.identity always,semigroupoidFn.compose arity=2); and the ST/Refmodifywrappers.The pack is the lowest-precedence directive source —
resolveModesgains a fourth tier: a local module-header pragma beats the consumer's--directivesfile, which beats a library author's@inline exportpragma, which beats the pack. Any entry can be opted out per target by naming it in the directives file with modedefault(restoring the built-in heuristics). Pack entries are best-effort like file entries, so modules absent from a build are simply never consulted; a parse failure of the embedded pack is a compiler bug and the test suite forces the parse.End-to-end effect
The new
Golden.DirectivePackgolden compiles a module with zero annotations.describeFruit = const "fruit: " <> show(Semigroup on functions) compiled to a runtime chain of dictionary applications before:With the pack the
semigroupFndictionary, theconstclosure and theshowaccessor all dissolve:A
half =<< half =<< half =<< Just 40chain that previously called a sharedbindFlippedworker per step now unfolds throughbindFlipped=>flip=>bindat each site and the Maybe binds collapse into folded cases;pipeline = identity <<< (_ + 1) <<< identitycompiles tofunction(x_S_1) return x_S_1 + 1 end.Corpus and benchmark numbers
Total
golden.luaacross the corpus drops from 739,666 to 503,370 bytes (−32%). The ~300-deep constant chains now fold to their final values at compile time:Bench.BindChain(a three-step Maybe bind chain driven hot by the benchmark harness) collapses toreturn { run = function(x_S_0) return x_S_0 + 3 end }. Its committed LuaJIT counter oracles record the effect: per-call closure allocations (function-bodyFNEW) 2 → 0, per-call table allocations (TNEW+TDUP) 4 → 0, trace aborts 3 → 0, blacklisted bytecodes 2 → 0. Under LuaJIT the linked artifact now times at parity with the hand-written ideal (median 0.0015svs0.0020s, n=1e6).The known costs: the 300-deep transformer stress goldens (LongStateBind, LongStackBind, LongWriterBind) grow 1–4% — their chains cannot collapse (see the derivation guard below) so some combinator unfolds are pure size — and two ordinary goldens shift within 2% from optimizer-equilibrium changes. Eval goldens are byte-identical across the corpus, which is the semantic safety net.
Supporting changes
Directive names accept
_and'(fix(inliner)): the target parser only accepted alphanumerics, so no pragma or directives file could ever nameEffect.Ref.modify_or a primed helper. Pre-existing DSL gap, needed by the pack.Derived directives require work-free arguments (
feat(optimizer)): #241 derives directives for specialization bindings likebind' = bind someDict. Pasting such a right-hand side re-evaluates the applied arguments per site — licensed when a user wrote the directive, but not when the compiler seeded it from the pack: witharity=1onbind, the ExceptT stress golden inlinedbind (bindExceptT monadIdentity)at ~300 sites, re-running the dictionary construction each time (+40% size). The derivation now fires only when every applied argument is a value — a reference, a literal, or a lambda. A settled dictionary literal still qualifies (its per-site copy meets the constructor folds and vanishes), which is what keeps the Maybe/Either collapses firing.Fixpoint backstop raised 100 → 1000 (
feat(optimizer)): directive-driven inlining folds a constant chain one layer per optimize round, so legitimate iteration counts now scale with the deepest such chain — the ~300-deep stress goldens converge between 400 and 1000 rounds (measured by bisecting the cap). The backstop still catches a pass that over-reports changes or loops, just later.Verification
Full suite green (
cabal test all), including the hand-written eval oracle of the new golden. The IR Optimizer and IR Inliner Hedgehog groups re-ran 10× with fresh seeds, no failures and no hangs.bench/cicounters match the re-accepted oracles; no other benchmark artifact changed by a single byte. HLint clean, no compiler warnings on any touched module.