You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Inlining in the compiler: current state and proposed changes #8733
"Inlining" in the compiler covers four different features that share one attribute name (@inline). They move through the pipeline in different ways and are stored in different build files. This issue describes how each works today, lists the problems found, and proposes what to change, as context for #8689 and the @inline item in #8624.
TL;DR
#
Feature
How it's written
What it means
Stored in
Rewatch notices a change?
A
Inline constants
@inline let x = 1 / @inline(1) let x: int
Changes the interface: the value is part of the signature
.cmi
✅ (the .cmi changes)
B
Externals
external f: … = "…"
The FFI call is emitted directly at each call site
.cmi
✅
C
Function inlining inside one module
Automatic, or @inline / @inline(never) on a function
Optimization hint
Nowhere; happens during compilation
n/a
D
Cross-module value and function inlining
Automatic (true/false/null/undefined); functions only with -bs-cross-module-opt
Optimization
.cmj
❌ can go stale
Feature A is handled by a frontend rewrite rather than a real AST node. Feature D has a correctness bug in incremental builds. #8689 extends D with an explicit opt-in. Before extending it, we should agree on the model.
1. How the pipeline works today
A. Inline constants (@inline let / @inline(lit) let)
Parser:@inline is kept as a plain attribute. In an implementation the value is the right-hand side (@inline let x = 1). In an interface it's the attribute payload (@inline(1) let x: int).
Frontend rewrite (compiler/frontend/bs_builtin_ppx.ml, signature_item_mapper and structure_item_mapper):
When the expression is a literal, the binding is rewritten into an external-like declaration (Pstr_primitive / Psig_value) with pval_prim = Some (Prim_inline_const c).
The implementation side gets a made-up type annotation (Ast_literal.type_int etc.).
pval_attributes is set to [].
There are two hand-written copies of this logic, one per side, with about five branches each.
Type checker:Primitive.parse_declaration turns Prim_inline_const c into Val_prim {prim_kind = Kind_inline_const c}.
Translation:translcore (transl_external_application) replaces each use with Lconst (lambda_of_inline_const c).
Build files:
.cmi: the constant itself, inside the value's Val_prim primitive description. Any change to the value changes the .cmi, so dependents are rebuilt correctly.
JS: nothing. A primitive doesn't produce a JS binding, so @inline constants are not exported from the generated module (for example, tests/tests/src/inline_const.mjs doesn't export f, f1, f5, f6). JS or genType consumers can't import them.
Interface matching:Primitive.coercible requires the same kind. An interface @inline(1) let x: int can only be satisfied by an @inline implementation with the same value. A plain let x = 1 doesn't match it, while an @inline implementation can be hidden behind a plain let x: int. It then gets a normal JS binding, and dependents use A.x instead of the constant: for A, the .resi decides what is inlined.
B. Externals
These follow the same path as A, with Prim_ffi → Kind_external spec. The full FFI spec is stored in the .cmi, so every call site emits the JS call directly.
Exception: when an external's module path is package-relative (@module("./foo")), Ast_external_process sets no_inline_cross_module. The interface then gets a plain val, and the implementation is wrapped in include (… : sig val … end). Other modules call through an ordinary exported binding, because the relative path only works from the declaring file.
C. Function inlining inside one module
Attributes:Translattribute reads @inline / @inline(always) / @inline(never) on a function expression or binding and stores it as Lfunction.attr.inline (Always_inline | Never_inline | Default_inline). @inline on anything that isn't a function gives warning 53 ("misplaced attribute").
Optimizer:Lam_pass_remove_alias does the beta reduction (substituting the arguments into the function body). Lam_analysis.ok_to_inline_fun_when_app decides when:
Default_inline → inline when the body size is under 5 (small_inline_size), when the call can be resolved statically via destruct_pattern, or when all arguments are constants, the size is under 10, and the body has no side effects.
Async functions and functions with a directive are never inlined (lfunction_can_be_inlined).
D. Cross-module inlining via .cmj
Export (Lam_stats_export.values_of_export). For each exported value, the .cmj stores {name; arity; persistent_closed_lambda}, where the last field is a Lambda term (the compiler's intermediate representation) that callers can copy:
true / false / null / undefined constants are always exported, whatever the flags.
Everything else is only exported when -bs-cross-module-opt is set (off by default; can be set through compiler-flags). Even then, only values that pass safe_to_inline are exported:
functions;
constant constructors and polymorphic variants, booleans, undefined.
Ints and strings are never exported. Functions qualify under one of two rules:
@inline functions and functors: exported only when closed (Lam_closure.is_closed), i.e. they don't capture any surrounding local values.
Other functions: exported when the size is under 5 and there are no free variables.
Js_cmj_format.get_result filters again when the .cmj is read: unless the flag is set, everything except the four constants is dropped.
None of this depends on the interface. With an A.resi declaring let flag: bool, dependents still get true copied in. The .resi only decides which values are exported; their stored bodies come from the implementation. The same holds for functions under -bs-cross-module-opt.
Use.Lam_compile_env.query_external_id_info looks up the other module's .cmj entry:
Lam_pass_remove_alias beta-reduces A.f(args) with the stored body.
compile_external_field_apply inlines stored functions during code generation.
Format. The .cmj is a 16-byte digest header followed by the marshalled data. to_file ~check_exists skips rewriting the file when its content is unchanged.
Observed with a two-module rewatch project (B uses A's values):
With no flags, let flag = true in A is copied into B as let f = true. let num = 1 and all functions are referenced as A.num / A.g(3), even @inline let g = ….
With -bs-cross-module-opt, both @inline let g = x => x + 1 and the unannotated let h = x => x * 2 were inlined (let a = 4; let b = 6).
Summary: what each build file stores
File
Inlining-related contents
Rewatch rebuilds dependents when it changes?
.cmi
Types, plus Val_prim descriptions: the full FFI spec for externals (B) and the literal for @inline constants (A). Also declaration locations.
Yes. This is the only signal rewatch uses today: compile.rs sets is_clean from the .cmi digest alone.
.cmj
Per exported value: arity, plus an optional persistent_closed_lambda (D: always-exported bool/null/undefined constants, plus small or @inline closed functions under the flag). Also effect information and hoisted exports.
The generated code. @inline constants and externals produce no binding.
n/a
2. Problems
2.1 Stale JS after incremental builds (bug on master)
A dependent copies values from A's .cmj, but rewatch only checks A's .cmi.
Repro 1 (no flags):
// A.resletflag=true// two spaces, so the edit below keeps every locationletnum=2// B.resletf=A.flag
Build, then change A to let flag = false and build again. Only A is recompiled. A's .cmi is byte-identical, A.mjs has let flag = false, but B.mjs still has let f = true.
Build, then change A.res to let flag = false. A.cmi is built from the .resi alone, so it stays byte-identical even when every line of A.res moves. B is never rebuilt and keeps let f = true.
For modules without an interface, the bug is mostly hidden in practice because the .cmi stores declaration locations, so most edits shift something and trigger the rebuild by accident. Modules with a .resi don't have this safety net: every change of such a value leaves dependents stale. #8689's .cmj digest check fixes this as a side effect.
2.2 @inline means three things
@inline is a signature-level constant (A), an optimization hint (C), and with #8689 an opt-in for cross-module inlining (D). Which one applies depends on whether the right-hand side is a literal, which is easy to get wrong. For example, @inline let x: int = 3 silently becomes an ordinary value with warning 53.
2.3 Inline constants are a frontend rewrite, not part of the AST
Implementations and interfaces disagree. Interfaces accept bigint; implementations don't. So @inline(12n) let x: bigint in an interface can't be implemented.
A type annotation disables inlining.
Other attributes on the binding are dropped (pval_attributes = []).
The frontend makes up a type annotation instead of letting the type checker compute the literal's type.
Prim_inline_const lives in the parsetree only to carry the result of the rewrite.
2.4 @inline constants are missing from the JS output
That's expected for externals, but surprising for something written as let, and it limits interop.
2.5 @inline on a function does nothing across modules by default
Without the global flag nothing is exported to .cmj. The global flag, meanwhile, inlines every small closed function, opted-in or not.
2.6 The global -bs-cross-module-opt flag isn't a clear contract
It applies per compiler invocation, its export rules are an internal heuristic (size < 5), and nothing documents it. #8689 makes cross-module inlining an explicit per-function choice, which would make the global flag largely redundant.
2.7 The .cmj constants bypass the interface
true/false/null/undefined values are copied into dependents even when the .resi only promises a type, so let flag: bool in an interface doesn't keep the value abstract. It's also always on: no attribute or flag enables or disables it. @inline(value) in a .resi is the explicit, interface-level way to do the same thing.
Resolve FFI externals during type checking #8728 (open): removes Prim_inline_const from the parsetree by resolving externals during type checking. Inline constants become external x: T = "#rescript-inline" with the @inline(<literal>) attribute kept, which overlaps with proposal 2.
The last option would make @inline mean the same thing inside and across modules. The open question is whether exporting bodies by default for every @inline function is acceptable, given the extra rebuilds.
Decide what happens to -bs-cross-module-opt. Once there's an explicit opt-in, either remove it or document it as "also export small closed functions automatically". For the bool/null/undefined constants that are always exported, either keep them, since proposal 1 makes them safe (though they still bypass the interface, see 2.7), or stop exporting them, at least when an interface hides the value, and leave interface-visible constants to @inline(value).
Decide whether @inline constants should be exported to JS (for example, also emit export const x = 1), so JS and genType consumers can use them.
5. Open questions
Should cross-module inlining of functions be opt-in per function only, or also available as a project-wide setting?
"Inlining" in the compiler covers four different features that share one attribute name (
@inline). They move through the pipeline in different ways and are stored in different build files. This issue describes how each works today, lists the problems found, and proposes what to change, as context for #8689 and the@inlineitem in #8624.TL;DR
@inline let x = 1/@inline(1) let x: int.cmi.cmichanges)external f: … = "…".cmi@inline/@inline(never)on a functiontrue/false/null/undefined); functions only with-bs-cross-module-opt.cmjFeature A is handled by a frontend rewrite rather than a real AST node. Feature D has a correctness bug in incremental builds. #8689 extends D with an explicit opt-in. Before extending it, we should agree on the model.
1. How the pipeline works today
A. Inline constants (
@inline let/@inline(lit) let)@inlineis kept as a plain attribute. In an implementation the value is the right-hand side (@inline let x = 1). In an interface it's the attribute payload (@inline(1) let x: int).compiler/frontend/bs_builtin_ppx.ml,signature_item_mapperandstructure_item_mapper):Pstr_primitive/Psig_value) withpval_prim = Some (Prim_inline_const c).Ast_literal.type_intetc.).pval_attributesis set to[].Primitive.parse_declarationturnsPrim_inline_const cintoVal_prim {prim_kind = Kind_inline_const c}.translcore(transl_external_application) replaces each use withLconst (lambda_of_inline_const c)..cmi: the constant itself, inside the value'sVal_primprimitive description. Any change to the value changes the.cmi, so dependents are rebuilt correctly.@inlineconstants are not exported from the generated module (for example,tests/tests/src/inline_const.mjsdoesn't exportf,f1,f5,f6). JS or genType consumers can't import them.Primitive.coerciblerequires the same kind. An interface@inline(1) let x: intcan only be satisfied by an@inlineimplementation with the same value. A plainlet x = 1doesn't match it, while an@inlineimplementation can be hidden behind a plainlet x: int. It then gets a normal JS binding, and dependents useA.xinstead of the constant: for A, the.residecides what is inlined.B. Externals
These follow the same path as A, with
Prim_ffi→Kind_external spec. The full FFI spec is stored in the.cmi, so every call site emits the JS call directly.Exception: when an external's module path is package-relative (
@module("./foo")),Ast_external_processsetsno_inline_cross_module. The interface then gets a plainval, and the implementation is wrapped ininclude (… : sig val … end). Other modules call through an ordinary exported binding, because the relative path only works from the declaring file.C. Function inlining inside one module
Translattributereads@inline/@inline(always)/@inline(never)on a function expression or binding and stores it asLfunction.attr.inline(Always_inline | Never_inline | Default_inline).@inlineon anything that isn't a function gives warning 53 ("misplaced attribute").Lam_pass_remove_aliasdoes the beta reduction (substituting the arguments into the function body).Lam_analysis.ok_to_inline_fun_when_appdecides when:Always_inline→ always inline;Never_inline→ never.Default_inline→ inline when the body size is under 5 (small_inline_size), when the call can be resolved statically viadestruct_pattern, or when all arguments are constants, the size is under 10, and the body has no side effects.lfunction_can_be_inlined).D. Cross-module inlining via
.cmjExport (
Lam_stats_export.values_of_export). For each exported value, the.cmjstores{name; arity; persistent_closed_lambda}, where the last field is a Lambda term (the compiler's intermediate representation) that callers can copy:true/false/null/undefinedconstants are always exported, whatever the flags.Everything else is only exported when
-bs-cross-module-optis set (off by default; can be set throughcompiler-flags). Even then, only values that passsafe_to_inlineare exported:undefined.Ints and strings are never exported. Functions qualify under one of two rules:
@inlinefunctions and functors: exported only when closed (Lam_closure.is_closed), i.e. they don't capture any surrounding local values.Js_cmj_format.get_resultfilters again when the.cmjis read: unless the flag is set, everything except the four constants is dropped.None of this depends on the interface. With an
A.resideclaringlet flag: bool, dependents still gettruecopied in. The.resionly decides which values are exported; their stored bodies come from the implementation. The same holds for functions under-bs-cross-module-opt.Use.
Lam_compile_env.query_external_id_infolooks up the other module's.cmjentry:Lam_pass_remove_aliasbeta-reducesA.f(args)with the stored body.Lam_compile.compile_external_fieldemits stored non-function constants directly.compile_external_field_applyinlines stored functions during code generation.Format. The
.cmjis a 16-byte digest header followed by the marshalled data.to_file ~check_existsskips rewriting the file when its content is unchanged.Observed with a two-module rewatch project (B uses A's values):
let flag = truein A is copied into B aslet f = true.let num = 1and all functions are referenced asA.num/A.g(3), even@inline let g = ….-bs-cross-module-opt, both@inline let g = x => x + 1and the unannotatedlet h = x => x * 2were inlined (let a = 4; let b = 6).Summary: what each build file stores
.cmiVal_primdescriptions: the full FFI spec for externals (B) and the literal for@inlineconstants (A). Also declaration locations.compile.rssetsis_cleanfrom the.cmidigest alone..cmjpersistent_closed_lambda(D: always-exported bool/null/undefined constants, plus small or@inlineclosed functions under the flag). Also effect information and hoisted exports..js@inlineconstants and externals produce no binding.2. Problems
2.1 Stale JS after incremental builds (bug on master)
A dependent copies values from A's
.cmj, but rewatch only checks A's.cmi.Repro 1 (no flags):
Build, then change A to
let flag = falseand build again. Only A is recompiled. A's.cmiis byte-identical,A.mjshaslet flag = false, butB.mjsstill haslet f = true.Repro 2 (
"compiler-flags": ["-bs-cross-module-opt"]):Build, then change the body to
x * 3. Only A is recompiled, andB.mjsstill haslet b = 6.Repro 3 (any module with a
.resi, no flags):Build, then change
A.restolet flag = false.A.cmiis built from the.resialone, so it stays byte-identical even when every line ofA.resmoves. B is never rebuilt and keepslet f = true.For modules without an interface, the bug is mostly hidden in practice because the
.cmistores declaration locations, so most edits shift something and trigger the rebuild by accident. Modules with a.residon't have this safety net: every change of such a value leaves dependents stale. #8689's.cmjdigest check fixes this as a side effect.2.2
@inlinemeans three things@inlineis a signature-level constant (A), an optimization hint (C), and with #8689 an opt-in for cross-module inlining (D). Which one applies depends on whether the right-hand side is a literal, which is easy to get wrong. For example,@inline let x: int = 3silently becomes an ordinary value with warning 53.2.3 Inline constants are a frontend rewrite, not part of the AST
Also tracked in #8624.
@inline(12n) let x: bigintin an interface can't be implemented.pval_attributes = []).Prim_inline_constlives in the parsetree only to carry the result of the rewrite.2.4
@inlineconstants are missing from the JS outputThat's expected for externals, but surprising for something written as
let, and it limits interop.2.5
@inlineon a function does nothing across modules by defaultWithout the global flag nothing is exported to
.cmj. The global flag, meanwhile, inlines every small closed function, opted-in or not.2.6 The global
-bs-cross-module-optflag isn't a clear contractIt applies per compiler invocation, its export rules are an internal heuristic (size < 5), and nothing documents it. #8689 makes cross-module inlining an explicit per-function choice, which would make the global flag largely redundant.
2.7 The
.cmjconstants bypass the interfacetrue/false/null/undefinedvalues are copied into dependents even when the.resionly promises a type, solet flag: boolin an interface doesn't keep the value abstract. It's also always on: no attribute or flag enables or disables it.@inline(value)in a.resiis the explicit, interface-level way to do the same thing.3. Related work
@inline(crossModule)→Cross_module_inline..cmjregardless of the flag (it must be closed, not async, and have no directive) and is always inlined at call sites..cmjdigest changes.Option.map,flatMap,getOr, etc. in the stdlib.@inlineconstants: give them a structural form".Prim_inline_constfrom the parsetree by resolving externals during type checking. Inline constants becomeexternal x: T = "#rescript-inline"with the@inline(<literal>)attribute kept, which overlaps with proposal 2.4. Proposals
Fix stale dependents independently of Add opt-in cross-module inlining for Option helpers #8689. Rebuild dependents when the
.cmjdigest changes, which is the rewatch part of Add opt-in cross-module inlining for Option helpers #8689. Land it on its own, or first in the stack, since it fixes a bug that exists today. It also removes the need for the accidental "locations in.cmi" safety net.Give inline constants a structural form (feature A):
primitive_repr = Prim_name of string | Prim_inline of inline_literal loc, using the literal's source spelling, the same pattern as the@aschange.@inline(lit) let x: tand for@inline let x(: t)? = lit.Kind_inline_const.Separate the meanings of
@inline. Possible shape:@inlineon a literal binding = signature constant (A);@inline/@inline(never)on a function = optimization hint (C);@inline(crossModule), or just "@inlineon an exported function exports its body".The last option would make
@inlinemean the same thing inside and across modules. The open question is whether exporting bodies by default for every@inlinefunction is acceptable, given the extra rebuilds.Decide what happens to
-bs-cross-module-opt. Once there's an explicit opt-in, either remove it or document it as "also export small closed functions automatically". For the bool/null/undefined constants that are always exported, either keep them, since proposal 1 makes them safe (though they still bypass the interface, see 2.7), or stop exporting them, at least when an interface hides the value, and leave interface-visible constants to@inline(value).Decide whether
@inlineconstants should be exported to JS (for example, also emitexport const x = 1), so JS and genType consumers can use them.5. Open questions
.cmjchange acceptable? Add opt-in cross-module inlining for Option helpers #8689 notes that unrelated.cmjchanges also trigger rebuilds, and stored Lambda bodies include locations.@inlinefunctors keep their special export path?