Skip to content

Ship a default @inline directive pack for the prelude/core forks - #327

Merged
Unisay merged 4 commits into
mainfrom
issue-242/default-inline-directive-pack
Jul 28, 2026
Merged

Ship a default @inline directive pack for the prelude/core forks#327
Unisay merged 4 commits into
mainfrom
issue-242/default-inline-directive-pack

Conversation

@Unisay

@Unisay Unisay commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #242.

The compiler now ships a default @inline directive pack for the prelude/core forks: a built-in set of inlining directives (Inliner.defaultDirectives, written in the --directives file 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, ...) 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, when, ...); the generic-representation glue (genericShow/genericEq/genericCompare at arity=2, to/from at arity=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/Ref modify wrappers.

The pack is the lowest-precedence directive source — resolveModes gains a fourth tier: a local module-header pragma beats the consumer's --directives file, which beats a library author's @inline export pragma, which beats the pack. Any entry can be opted out per target by naming it in the directives file with mode default (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.DirectivePack golden compiles a module with zero annotations. describeFruit = const "fruit: " <> show (Semigroup on functions) compiled to a runtime chain of dictionary applications before:

local Golden_DirectivePack_Test_describeFruit = Data_Semigroup_append({
  append = function(f_S_2)
    return function(g_S_0)
      return function(x_S_2)
        return Data_Semigroup_append(Data_Semigroup_semigroupString)(f_S_2(x_S_2))(g_S_0(x_S_2))
      end
    end
  end
})(function()
  return "fruit: "
end)(Data_Show_show(Golden_DirectivePack_Test_showFruit))

With the pack the semigroupFn dictionary, the const closure and the show accessor all dissolve:

M.Golden_DirectivePack_Test_describeFruit = function(x_S_2)
  return Data_Semigroup_semigroupString.append("fruit: ")(Data_Show_show(Golden_DirectivePack_Test_showFruit)(x_S_2))
end

A half =<< half =<< half =<< Just 40 chain that previously called a shared bindFlipped worker per step now unfolds through bindFlipped => flip => bind at each site and the Maybe binds collapse into folded cases; pipeline = identity <<< (_ + 1) <<< identity compiles to function(x_S_1) return x_S_1 + 1 end.

Corpus and benchmark numbers

Total golden.lua across 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:

golden before after
LongMaybeBindModule 104,416 B 56 B
LongMaybeBind 97,402 B 510 B
LongApplyChain 26,216 B 513 B
LongBindFlipped 24,447 B 627 B

Bench.BindChain (a three-step Maybe bind chain driven hot by the benchmark harness) collapses to return { run = function(x_S_0) return x_S_0 + 3 end }. Its committed LuaJIT counter oracles record the effect: per-call closure allocations (function-body FNEW) 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.0015s vs 0.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 name Effect.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 like bind' = 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: with arity=1 on bind, the ExceptT stress golden inlined bind (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/ci counters match the re-accepted oracles; no other benchmark artifact changed by a single byte. HLint clean, no compiler warnings on any touched module.

Unisay added 4 commits July 28, 2026 11:06
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[fork-ffi] Ship a default @inline directive pack for the prelude/core forks

1 participant