Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ https://github.com/purescript-lua/purescript-lua/discussions/categories/ideas
- [x] FFI with Lua.
- [x] Dead Code Elimination (DCE).
- [x] Code inlining, tunable with graduated `@inline` directives
(`always`/`never`/`arity=N`, per-field accessors, and a project-wide
`--directives` file).
(`always`/`never`/`arity=N`, per-field accessors, a project-wide
`--directives` file, and a shipped default pack for the prelude/core
forks).
- [x] [Package Set](https://github.com/purescript-lua/purescript-lua-package-sets) for PureScript/Lua libs.
- [x] All core libs added to the package set.
- [x] First-class [Spago](https://github.com/purescript/spago) backend: `spago build`, `spago run`, and `spago test` target Lua via `pslua`.
Expand Down Expand Up @@ -191,7 +192,7 @@ field of a record the binding is (or returns), and a mode:
- `default` explicitly resets the target to the built-in heuristics, masking
any weaker directive.

Directives come from three sources, most specific first:
Directives come from four sources, most specific first:

1. **Module-header pragmas** — comment lines above `module` in the defining
module, naming its own bindings: `-- @inline myBinding arity=2`.
Expand All @@ -202,12 +203,21 @@ Directives come from three sources, most specific first:
cover optional dependencies.
3. **Exported pragmas** — `-- @inline export myBinding always` in the
defining module travels with the library as its author's recommendation.

A local pragma beats the file, and the file beats an exported pragma, per
target. Foreign bindings accept only whole-binding `always`/`never`/`default`
(their implementation is opaque to the optimizer). Note that `spago run`
re-invokes the backend without build-phase flags, so `--directives` (like all
build flags) applies to `spago build` output, not to the `--run` re-link.
4. **The default directive pack** — the compiler ships directives for the
prelude/core forks' ubiquitous tiny combinators (class member accessors
like `map` and `bind` at `arity=1`, `bindFlipped`, the function-instance
dictionary methods like `semigroupFn.append`, `otherwise`, the generics
glue, the ST/Ref `modify` wrappers), so common dictionary-parameterized
code specializes away with no annotations in user code.

A local pragma beats the file, the file beats an exported pragma, and every
explicit source beats the default pack, per target. To opt a pack entry out,
name it in the directives file with mode `default` (restoring the built-in
heuristics) or any stronger mode. Foreign bindings accept only whole-binding
`always`/`never`/`default` (their implementation is opaque to the optimizer).
Note that `spago run` re-invokes the backend without build-phase flags, so
`--directives` (like all build flags) applies to `spago build` output, not to
the `--run` re-link.

Specializations of an `arity=N` target need no directives of their own: a
top-level binding that applies the target to `k` arguments — a hand-written
Expand Down
10 changes: 4 additions & 6 deletions bench/goldens/fnew_Bench.BindChain.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
chunk: Bench.BindChain.lua
runtime: LuaJIT 2.1.1741730670
main-chunk FNEW: 2
function-body FNEW: 2
total FNEW: 4
prototypes: 5
main-chunk FNEW: 1
function-body FNEW: 0
total FNEW: 1
prototypes: 2
function-body FNEW sites:
Bench.BindChain.lua:18
Bench.BindChain.lua:17
10 changes: 3 additions & 7 deletions bench/goldens/tnew_Bench.BindChain.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
chunk: Bench.BindChain.lua
runtime: LuaJIT 2.1.1741730670
main-chunk TNEW+TDUP: 1
function-body TNEW+TDUP: 4
total TNEW+TDUP: 5
prototypes: 5
function-body TNEW+TDUP: 0
total TNEW+TDUP: 1
prototypes: 2
function-body TNEW+TDUP sites:
Bench.BindChain.lua:5 TDUP
Bench.BindChain.lua:10 TDUP
Bench.BindChain.lua:14 TDUP
Bench.BindChain.lua:17 TDUP
9 changes: 1 addition & 8 deletions bench/goldens/trace_bind_chain.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,5 @@ spec: bind_chain
runtime: LuaJIT 2.1.1741730670
workload: n=1000000 reps=4 result=3000000
aborts (distinct site -- reason):
Bench.BindChain.lua:17 -- NYI: bytecode FNEW
Bench.BindChain.lua:18 -- NYI: bytecode FNEW
Bench.BindChain.lua:22 -- NYI: bytecode UCLO
bytecode end state (J*=compiled, I*=blacklisted):
Bench.BindChain.lua:1 JFUNCF
Bench.BindChain.lua:13 IFUNCF
Bench.BindChain.lua:17 JFUNCF
Bench.BindChain.lua:9 IFUNCF
counts: aborts=3 compiled=2 blacklisted=2
counts: aborts=0 compiled=0 blacklisted=0
23 changes: 23 additions & 0 deletions changelog.d/20260728_120000_unisay_default_directive_pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
### Added

- The compiler now ships a default `@inline` directive pack for the
prelude/core forks (`Inliner.defaultDirectives`): class member accessors
(`map`, `bind`, `append`, ...) at `arity=1`, the tiny dictionary-
parameterized combinators (`bindFlipped`, `applySecond`, `composeKleisli`,
...), the function-instance dictionary methods
(`semigroupFn.append arity=2`, `categoryFn.identity always`), the generics
glue (`genericShow`/`genericEq`/`genericCompare`), and the ST/Ref `modify`
wrappers. The pack is the lowest-precedence directive source: a consumer's
`--directives` file or a library author's module-header pragma overrides
any entry, and an explicit `default` mode masks one back to the built-in
heuristics (#242).

### Changed

- Directive targets may now contain `_` and `'`, matching PureScript
identifier syntax (previously `Effect.Ref.modify_` or a primed name could
not be named by any pragma or directives file).
- The optimizer fixpoint iteration backstop is raised from 100 to 1000
rounds: directive-driven inlining folds a constant chain one layer per
round, so legitimate iteration counts scale with the deepest such chain
in the module (the ~300-deep golden stress chains need several hundred).
4 changes: 3 additions & 1 deletion exe/Cli.hs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ options = do
, green $ indent 2 "Example: Data.Lens.over arity=2"
, "A local module-header pragma overrides the file;"
<> softbreak
<> "the file overrides @inline export pragmas."
<> "the file overrides @inline export pragmas,"
<> softbreak
<> "which override the shipped default directive pack."
]
]

Expand Down
8 changes: 7 additions & 1 deletion lib/Language/PureScript/Backend/IR.hs
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,15 @@ mkModule directives cfnModule contextDataTypes = do
(localModes, exportModes) ← parseAnnotations cfnModule
let fileModes =
Map.findWithDefault mempty (Cfn.moduleName cfnModule) directives
packModes =
Map.findWithDefault
mempty
(Cfn.moduleName cfnModule)
Inliner.defaultDirectives
runRepM
Context
{ annotations = Inliner.resolveModes localModes fileModes exportModes
{ annotations =
Inliner.resolveModes localModes fileModes exportModes packModes
, headerTargets = Map.keysSet localModes <> Map.keysSet exportModes
, contextModule = cfnModule
, contextDataTypes
Expand Down
117 changes: 104 additions & 13 deletions lib/Language/PureScript/Backend/IR/Inliner.hs
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,26 @@ An inlining directive names a target and a mode:
The bare target names a whole binding. @.label@ names one field of a
dictionary-record binding, @...label@ one field of the record a binding
returns when applied (@f(x).label@) — a policy for one method instead of
the whole record. Directives come from three explicit sources, layered by
the whole record. Directives come from four explicit sources, layered by
specificity (most specific wins, per target):

1. a local module-header pragma (no @export@) in the defining module;
2. the project-wide @--directives@ file;
3. an @\@inline export@ module-header pragma — a default the library
author ships with the module, overridable by the consumer's file.
author ships with the module, overridable by the consumer's file;
4. the default directive pack the compiler ships for the prelude/core
forks ('defaultDirectives') — generic defaults that any explicit
source, including a library author's @export@ pragma, overrides.

'resolveModes' performs exactly this layering; a winning @default@ still
masks the lower tiers and resolves to no annotation — the built-in
heuristics. Module-header pragmas name own-module bindings only and are
validated strictly (a target matching nothing is an error); file entries
are fully qualified and best-effort (a shared file may cover modules
absent from the build). In a whole-program optimizer @export@ needs no
transitivity machinery: the resolved annotation rides the binding into
the uber-module, so it is simply the weakest explicit tier.
validated strictly (a target matching nothing is an error); file and
pack entries are fully qualified and best-effort (a shared file may
cover modules absent from the build). In a whole-program optimizer
@export@ needs no transitivity machinery: the resolved annotation rides
the binding into the uber-module, so it is simply the weakest
author-written tier.

The resolved winner travels to the optimizer's decision through several
stages:
Expand Down Expand Up @@ -157,22 +161,25 @@ data Pragma = Pragma
-- | The parsed contents of a @--directives@ file, grouped by module.
type Directives = Map ModuleName (Map Target Mode)

{- | Resolve the three explicit directive sources for a module's targets into
{- | Resolve the four explicit directive sources for a module's targets into
the annotations to attach, layered by precedence: a local module-header
directive beats the project directives file, which beats an @export@-scoped
header directive. A winning 'ModeDefault' still occupies its key — masking
the lower tiers — and attaches 'Nothing' (the built-in heuristics).
header directive, which beats the shipped default pack. A winning
'ModeDefault' still occupies its key — masking the lower tiers — and
attaches 'Nothing' (the built-in heuristics).
-}
resolveModes
∷ Map Target Mode
-- ^ module-header pragmas, local scope (highest precedence)
→ Map Target Mode
-- ^ the @--directives@ file slice for this module
→ Map Target Mode
-- ^ module-header pragmas, export scope (lowest explicit tier)
-- ^ module-header pragmas, export scope (lowest author-written tier)
→ Map Target Mode
-- ^ the default pack slice for this module (lowest explicit tier)
→ Map Target (Maybe Annotation)
resolveModes localModes fileModes exportModes =
Map.unions [localModes, fileModes, exportModes] <&> \case
resolveModes localModes fileModes exportModes packModes =
Map.unions [localModes, fileModes, exportModes, packModes] <&> \case
ModeDefault → Nothing
ModeAnnotation ann → Just ann

Expand Down Expand Up @@ -285,3 +292,87 @@ symbol = void . ML.symbol sc

sc ∷ Parser ()
sc = ML.space (MC.hspace1 @_ @Text) empty empty

--------------------------------------------------------------------------------
-- Default directive pack ------------------------------------------------------

{- | The directive pack the compiler ships for the prelude/core forks: the
tiny, ubiquitous dictionary-parameterized combinators whose inlining starts
the specialization cascade but which the built-in size heuristics leave
shared. The lowest explicit tier ('resolveModes'): a consumer's
@--directives@ file or a library author's module-header pragma overrides
any entry, and an explicit @default@ mode masks one back to the built-in
heuristics. Entries are best-effort like file entries — modules absent
from the build are simply never consulted.

A syntax error in the pack is a compiler bug, hence the unsafe parse
(the test suite forces the parse).
-}
defaultDirectives ∷ Directives
defaultDirectives =
case Megaparsec.parse
(directivesFileParser <* Megaparsec.eof)
"<default directive pack>"
defaultDirectivesSource of
Left errorBundle →
error . toText $ Megaparsec.errorBundlePretty errorBundle
Right directives → directives

defaultDirectivesSource ∷ Text
defaultDirectivesSource =
unlines
[ "-- Class member accessors: applying one to a dictionary resolves"
, "-- to the instance method and starts the inlining cascade."
, "Control.Applicative.pure arity=1"
, "Control.Apply.apply arity=1"
, "Control.Bind.bind arity=1"
, "Control.Category.identity arity=1"
, "Control.Semigroupoid.compose arity=1"
, "Control.Semigroupoid.composeFlipped arity=1"
, "Data.Functor.map arity=1"
, "Data.Generic.Rep.from arity=1"
, "Data.Generic.Rep.to arity=1"
, "Data.Monoid.mempty arity=1"
, "Data.Semigroup.append arity=1"
, ""
, "-- Dictionary-parameterized combinators too small to earn a"
, "-- shared binding once their dictionary is known."
, "Control.Applicative.liftA1 arity=1"
, "Control.Applicative.unless arity=1"
, "Control.Applicative.when arity=1"
, "Control.Apply.applyFirst arity=1"
, "Control.Apply.applySecond arity=1"
, "Control.Bind.bindFlipped arity=1"
, "Control.Bind.composeKleisli arity=1"
, "Control.Bind.composeKleisliFlipped arity=1"
, "Control.Bind.join arity=1"
, "Data.Functor.flap arity=1"
, "Data.Functor.mapFlipped arity=1"
, "Data.Functor.void arity=1"
, "Data.Functor.voidLeft arity=1"
, "Data.Functor.voidRight arity=1"
, ""
, "-- Generic-representation glue: dissolving it lets the derived"
, "-- Show/Eq/Ord instances collapse through the Rep constructors."
, "Data.Eq.Generic.genericEq arity=2"
, "Data.Ord.Generic.genericCompare arity=2"
, "Data.Show.Generic.genericShow arity=2"
, ""
, "-- Identities and plain tiny functions."
, "Data.Boolean.otherwise always"
, "Data.Function.apply arity=2"
, "Data.Function.applyFlipped arity=2"
, "Data.Function.const arity=1"
, "Data.Function.flip arity=1"
, ""
, "-- Function-instance dictionary methods."
, "Control.Category.categoryFn.identity always"
, "Control.Semigroupoid.semigroupoidFn.compose arity=2"
, "Data.Functor.functorFn.map arity=2"
, "Data.Semigroup.semigroupFn.append arity=2"
, ""
, "-- ST/Ref glue around the foreign modify implementation."
, "Control.Monad.ST.Internal.modify arity=1"
, "Effect.Ref.modify arity=1"
, "Effect.Ref.modify_ arity=1"
]
5 changes: 4 additions & 1 deletion lib/Language/PureScript/Backend/IR/Names.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ newtype Name = Name {nameToText ∷ Text}
deriving stock (Generic)
deriving (Show) via (Quiet Name)

-- | A PureScript identifier may also contain @_@ and @'@.
nameParser ∷ Megaparsec.Parsec Void Text Name
nameParser = Name <$> Megaparsec.takeWhile1P (Just "name char") isAlphaNum
nameParser = Name <$> Megaparsec.takeWhile1P (Just "name char") isNameChar
where
isNameChar c = isAlphaNum c || c == '_' || c == '\''

{- | The binder for the (unused) result of a discarded action, minted by
the magic-do transform: @_@ is the conventional Lua throwaway, exempt
Expand Down
33 changes: 29 additions & 4 deletions lib/Language/PureScript/Backend/IR/Optimizer.hs
Original file line number Diff line number Diff line change
Expand Up @@ -581,10 +581,15 @@ policy composes over application. Let @f@ carry @arity=N@ and let
arithmetic.

Pasting a partial application re-evaluates its argument expressions per
site, exactly as pasting the target at a qualifying non-lambda site
does ('inlineSaturatedCall'): the explicit directive on the target is
what licenses the duplication, so no cheapness guard applies to the
arguments.
site. At a user-marked call site ('inlineSaturatedCall') the explicit
directive on the target licenses that duplication, but a derived
directive is compiler-initiated: the derivation fires only when every
applied argument is a value, work-free to repeat — a reference, a
literal (a settled dictionary literal is the winning shape: its
per-site copy meets the constructor folds and vanishes), or a lambda.
A computed argument — say a dictionary built by applying a
transformer's instance function — keeps the specialization a shared
binding instead of re-running the computation per site.

The derivation reads the module settled by the post-merge optimize+dce
fixpoint ('settlePhase'), not the pristine input: a specialization
Expand Down Expand Up @@ -623,6 +628,7 @@ derivedInlinePolicy explicit UberModule {uberModuleBindings} =
Standalone (qname, expr)
| not (hasRootDirective qname)
, (Ref _ headName, args@(_ : _)) ← unwindApp expr
, all workFreeToRepeat args
, Just target ← refQName headName
, Just arity ←
Map.lookup target (policyArity explicit)
Expand All @@ -635,6 +641,25 @@ derivedInlinePolicy explicit UberModule {uberModuleBindings} =
mempty {policyArity = Map.singleton qname (arity - applied)}
_ → derived

-- Pasting re-evaluates the applied arguments per site; see
-- Note [Derived inline directives] for why the derivation requires
-- them work-free while a user-marked site does not. Values qualify
-- (references, literals, lambdas — a settled dictionary literal
-- argument is the winning shape, its per-site copy folds away);
-- anything that computes (an application, a case, a let) does not.
workFreeToRepeat ∷ Exp → Bool
workFreeToRepeat = \case
Ref {} → True
LiteralInt {} → True
LiteralFloat {} → True
LiteralString {} → True
LiteralChar {} → True
LiteralBool {} → True
LiteralArray _ elems → all workFreeToRepeat elems
LiteralObject _ props → all (workFreeToRepeat . snd) props
AbsN {} → True
_ → False

hasRootDirective ∷ QName → Bool
hasRootDirective qname =
qname `Set.member` policyAlways explicit
Expand Down
13 changes: 8 additions & 5 deletions lib/Language/PureScript/Backend/IR/Pass.hs
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,16 @@ renderPassCheckFailure = \case
<> (show <$> toList violations)

{- | Iteration backstop for 'RunFixpoint'. Convergence normally takes a
handful of rounds, so this is far above anything legitimate: hitting it
means a pass over-reports changes or genuinely loops — a bug, which the
checked runner turns into a 'FixpointDivergence' while the production
runner accepts the (correct, possibly under-optimized) module reached.
handful of rounds, but directive-driven inlining folds a constant chain
one layer per round, so legitimate rounds scale with the deepest such
chain in the module (the ~300-deep golden stress chains need several
hundred). Hitting the backstop anyway means a pass over-reports changes
or genuinely loops — a bug, which the checked runner turns into a
'FixpointDivergence' while the production runner accepts the (correct,
possibly under-optimized) module reached.
-}
maxFixpointIterations ∷ Natural
maxFixpointIterations = 100
maxFixpointIterations = 1000

--------------------------------------------------------------------------------
-- Runners ---------------------------------------------------------------------
Expand Down
Loading