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
65 changes: 61 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ https://github.com/purescript-lua/purescript-lua/discussions/categories/ideas
- [x] Lua code bundling: emits either a Lua module (a file that returns a table with functions) or an application (a file that executes itself).
- [x] FFI with Lua.
- [x] Dead Code Elimination (DCE).
- [x] Code inlining.
- [x] Code inlining, tunable with graduated `@inline` directives
(`always`/`never`/`arity=N`, per-field accessors, and a project-wide
`--directives` file).
- [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 @@ -116,9 +118,10 @@ This will build and install executable `pslua.exe`
C:\cabal\bin\pslua --help
pslua - a PureScript backend for Lua

Usage: pslua.exe [--foreign-path FOREIGN-PATH] [--ps-output PS-PATH]
[--lua-output-file LUA-OUT-FILE] [--output-lua-ast]
[--output-ir] [--lint-ir] [-e|--entry ENTRY] [--run ENTRY]
Usage: pslua [--foreign-path FOREIGN-PATH] [--ps-output PS-PATH]
[--lua-output-file LUA-OUT-FILE] [--directives DIRECTIVES-FILE]
[--output-lua-ast] [--output-ir] [--lint-ir] [--max-locals N]
[--max-upvalues N] [-e|--entry ENTRY] [--run ENTRY]

Compile PureScript's CoreFn to Lua

Expand All @@ -131,12 +134,26 @@ Available options:
--lua-output-file LUA-OUT-FILE
Path to write compiled Lua file to.
Default: main.lua
--directives DIRECTIVES-FILE
Path to a file with project-wide inlining directives,
one per line: <Module>.<binding><accessor?> <mode>
- accessor: .label or ...label
- mode: default | never | always | arity=N
Example: Data.Lens.over arity=2
A local module-header pragma overrides the file;
the file overrides @inline export pragmas.
--output-lua-ast Output Lua AST.
Default: false
--output-ir Output IR.
Default: false
--lint-ir Check IR invariants after every optimizer pass (debug).
Default: false
--max-locals N Target Lua VM's hard limit on local variables
per function (LUAI_MAXVARS).
Default: 200 (Lua 5.1)
--max-upvalues N Target Lua VM's hard limit on upvalues
per function (LUAI_MAXUPVALUES).
Default: 60 (Lua 5.1)
-e,--entry ENTRY Where to start compilation.
Could be one of the following formats:
- Application format: <Module>.<binding>
Expand All @@ -151,3 +168,43 @@ Available options:
Example: Acme.App.main
-h,--help Show this help text
```

## Inlining directives

The optimizer's inlining decisions can be tuned per binding with `@inline`
directives. A directive names a target, optionally an accessor selecting one
field of a record the binding is (or returns), and a mode:

```
@inline [export] name[.label|...label] (default | never | always | arity=N)
```

- `always` / `never` force or forbid inlining the target.
- `arity=N` inlines the target only at call sites applying at least N
arguments — a partial application stays a shared reference. This is the
switch that starts an abstraction-elimination cascade: the pasted body
meets beta reduction and the case-of-known-constructor folds, so wrappers
like `runOp (Op f) x` collapse to `f x` at every saturated site.
- `.label` targets one field of a dictionary-record binding, `...label` one
field of the record a binding returns when applied (`f(x).label`) — a
policy for a single method rather than the whole record.
- `default` explicitly resets the target to the built-in heuristics, masking
any weaker directive.

Directives come from three sources, most specific first:

1. **Module-header pragmas** — comment lines above `module` in the defining
module, naming its own bindings: `-- @inline myBinding arity=2`.
2. **A project directives file** (`--directives inline.txt`) — one directive
per line with fully-qualified names and no `@inline` prefix
(`Data.Lens.over arity=2`); `--` comments and blank lines are allowed.
Entries that match nothing in the build are ignored, so a shared file can
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.
31 changes: 31 additions & 0 deletions changelog.d/20260712_154500_unisay_graduated_inline_directives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
### Added

- Graduated `@inline` directives (#232). The pragma grammar grows from binary
`always`/`never` to `[export] name[.label|...label]
(default|never|always|arity=N)`: `arity=N` inlines a binding only at call
sites applying at least N arguments (bypassing the size budget) and pins it
as a shared reference elsewhere; the accessor forms attach a policy to one
dictionary field (`.label`) or to a field of the record a binding returns
(`...label`) instead of the whole record; `default` explicitly resets a
target to the built-in heuristics. A new `--directives <file>` option
supplies fully-qualified directives project-wide, layered by specificity: a
local module-header pragma beats the file, which beats `@inline export`
pragmas shipped by the defining module, which beat the heuristics. Unmatched
file entries are ignored, so a shared file can cover optional dependencies.

- Constructor-eliminating reads now fold through a saturated application of a
*reference* to a top-level constructor binding (`(Op f).value0` resolves to
`f` without pasting the constructor), and projections sink through `let`
bindings to meet the record they select from. Together these let a
directive-driven paste collapse through the beta/case-of-known-constructor
cascade; as a side effect the existing specialize pass folds deeper (the
`LongReaderBind`/`LongWriterBind` goldens shrink, with unchanged runtime
output).

### Fixed

- An `@inline` annotation on a binding whose right-hand side is a bare
application, variable reference, or record update was silently dropped
during translation, so pragmas like `@inline foo never` had no effect on
point-free definitions. The annotation now lands on the binding root for
those shapes too.
21 changes: 21 additions & 0 deletions exe/Cli.hs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ data Args = Args
{ foreignPath ∷ Tagged "foreign" (SomeBase Dir)
, psOutputPath ∷ Tagged "output" (SomeBase Dir)
, luaOutputFile ∷ Tagged "output-lua" (SomeBase File)
, directivesFile ∷ Maybe (Tagged "directives" (SomeBase File))
, outputIR ∷ Maybe ExtraOutput
, outputLuaAst ∷ Maybe ExtraOutput
, lintIR ∷ Tagged "lint-ir" Bool
Expand Down Expand Up @@ -96,6 +97,26 @@ options = do
<> bold "Default: main.lua"
]

directivesFile ←
optional . option (eitherReader (bimap displayException Tagged . parseSomeFile)) $
fold
[ metavar "DIRECTIVES-FILE"
, long "directives"
, helpDoc . Just $
vsep
[ "Path to a file with project-wide inlining directives,"
<> softbreak
<> "one per line:"
<+> magenta "<Module>.<binding><accessor?> <mode>"
, "- accessor:" <+> magenta ".label" <+> "or" <+> magenta "...label"
, "- mode:" <+> magenta "default | never | always | arity=N"
, green $ indent 2 "Example: Data.Lens.over arity=2"
, "A local module-header pragma overrides the file;"
<> softbreak
<> "the file overrides @inline export pragmas."
]
]

outputLuaAst ←
flag Nothing (Just OutputLuaAst) . fold $
[ long "output-lua-ast"
Expand Down
24 changes: 23 additions & 1 deletion exe/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Data.Text qualified as Text
import Language.PureScript.Backend (CompilationResult (..))
import Language.PureScript.Backend qualified as Backend
import Language.PureScript.Backend.IR qualified as IR
import Language.PureScript.Backend.IR.Inliner qualified as Inliner
import Language.PureScript.Backend.IR.Pass
( PassCheckFailure
, renderPassCheckFailure
Expand All @@ -24,13 +25,15 @@ import Path (Abs, Dir, Path, SomeBase (..), replaceExtension, toFilePath)
import Path.IO qualified as Path
import Prettyprinter (defaultLayoutOptions, layoutPretty)
import Prettyprinter.Render.Text (renderIO)
import Text.Megaparsec qualified as Megaparsec
import Text.Pretty.Simple (pHPrint)

main ∷ IO ()
main = Utf8.withUtf8 do
Cli.Args
{ foreignPath
, luaOutputFile
, directivesFile
, outputIR
, outputLuaAst
, lintIR
Expand All @@ -47,6 +50,19 @@ main = Utf8.withUtf8 do
Path.Abs a → pure a
Path.Rel r → Path.makeAbsolute r

directives ← case directivesFile of
Nothing → pure mempty
Just (Tagged someFile) → do
path ←
case someFile of
Path.Abs a → pure a
Path.Rel r → Path.makeAbsolute r
contents ← decodeUtf8 <$> readFileBS (toFilePath path)
let parser = Inliner.directivesFileParser <* Megaparsec.eof
case Megaparsec.parse parser (toFilePath path) contents of
Left errorBundle → die $ Megaparsec.errorBundlePretty errorBundle
Right parsed → pure parsed

-- `--run` overrides `--entry`: Spago's run phase invokes the backend a second
-- time as `pslua --run <Entry>` (without the build-phase args), so the entry
-- to compile comes from `--run` when present.
Expand All @@ -58,7 +74,13 @@ main = Utf8.withUtf8 do
-- Stay silent in run mode so the program's own stdout isn't polluted (the
-- output may be piped); Spago already logs the run/build phases itself.
when (isNothing runEntry) $ putTextLn "PS Lua: compiling ..."
Backend.compileModules psOutputPath foreignDir lintIR luaLimits entry
Backend.compileModules
psOutputPath
foreignDir
lintIR
luaLimits
directives
entry
& handleModuleNotFoundError
& handleModuleDecodingError
& handleCoreFnError
Expand Down
6 changes: 4 additions & 2 deletions lib/Language/PureScript/Backend.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Control.Monad.Oops qualified as Oops
import Data.Map qualified as Map
import Data.Tagged (Tagged (..), untag)
import Language.PureScript.Backend.IR qualified as IR
import Language.PureScript.Backend.IR.Inliner qualified as Inliner
import Language.PureScript.Backend.IR.Linker qualified as Linker
import Language.PureScript.Backend.IR.Optimizer
( optimizedUberModule
Expand Down Expand Up @@ -40,14 +41,15 @@ compileModules
→ Tagged "foreign" (Path Abs Dir)
→ Tagged "lint-ir" Bool
→ LuaLimits
→ Inliner.Directives
→ AppOrModule
→ ExceptT (Variant e) IO CompilationResult
compileModules outputDir foreignDir lintIR limits appOrModule = do
compileModules outputDir foreignDir lintIR limits directives appOrModule = do
let entryModuleName = entryPointModule appOrModule
cfnModules ← CoreFn.readModuleRecursively outputDir entryModuleName
let dataDecls = IR.collectDataDeclarations cfnModules
irResults ← forM (Map.toList cfnModules) \(_psModuleName, cfnModule) →
Oops.hoistEither $ IR.mkModule cfnModule dataDecls
Oops.hoistEither $ IR.mkModule directives cfnModule dataDecls
let (needsRuntimeLazys, irModules) = unzip irResults
let linkedModule = Linker.makeUberModule (linkerMode appOrModule) irModules
-- Lift the allowlisted foreign exports to IR primops before optimizing, so
Expand Down
Loading