Inlining in the compiler: current state and proposed changes
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 22/100
- issue の種類
- リファクタリング
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- ocaml
調査の方向性
This is a meta/design issue: read sections 2 and 4 first, then the referenced code paths — compiler/frontend/bs_builtin_ppx.ml (feature A rewrite), Lam_stats_export/Lam_compile_env (feature D, .cmj export) and rewatch's compile.rs where is_clean is set from the .cmi digest alone. The only newcomer-plausible slice is proposal 1: make rewatch rebuild dependents when the .cmj digest changes, verified with the two repros in 2.1 (B.mjs must update). Done means dependents rebuild on .cmj change; the rest awaits agreement with #8689 and #8624.
索引モデルが issue の本文から書いたものです。
説明
"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. The call-site attribute @inlined is accepted but does nothing. #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:
@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). - Frontend rewrite (
compiler/frontend/bs_builtin_ppx.ml,signature_item_mapperandstructure_item_mapper):- When the expression is a literal, the binding is rewritten into an external-like declaration (
Pstr_primitive/Psig_value) withpval_prim = Some (Prim_inline_const c). - The implementation side gets a made-up type annotation (
Ast_literal.type_intetc.). pval_attributesis set to[].- There are two hand-written copies of this logic, one per side, with about five branches each.
- When the expression is a literal, the binding is rewritten into an external-like declaration (
- Type checker:
Primitive.parse_declarationturnsPrim_inline_const cintoVal_prim {prim_kind = Kind_inline_const c}. - Translation:
translcore(transl_external_application) replaces each use withLconst (lambda_of_inline_const c). - Build files:
.cmi: the constant itself, inside the value'sVal_primprimitive 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
@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.
- Interface matching:
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.
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:
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"). - Call sites:
@inlinedat a call site is parsed intoLapply.ap_inlined(translcore,translmodfor functor application). However, no pass incompiler/corereadsap_inlined; its only use inlam_pass_remove_alias.mlis commented out. The attribute is checked and then ignored. - Optimizer:
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.- 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/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:- functions;
- constant constructors and polymorphic variants, booleans,
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.- Other functions: exported when the size is under 5 and there are no free variables.
-
Js_cmj_format.get_resultfilters again when the.cmjis read: unless the flag is set, everything except the four constants is dropped.
Use. Lam_compile_env.query_external_id_info looks up the other module's .cmj entry:
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 .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 = 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 = …. - With
-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
| 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. |
No on master. #8689 adds this. |
.js |
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.res
let flag = true // two spaces, so the edit below keeps every location
let num = 2
// B.res
let f = 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.
Repro 2 ("compiler-flags": ["-bs-cross-module-opt"]):
// A.res
let h = x => x * 2
// B.res
let b = A.h(3)
Build, then change the body to x * 3. Only A is recompiled, and B.mjs still has let b = 6.
It's mostly hidden in practice because the .cmi stores declaration locations, so most edits shift something and trigger the rebuild by accident. #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
Also tracked in #8624.
- Implementations and interfaces disagree. Interfaces accept bigint; implementations don't. So
@inline(12n) let x: bigintin 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_constlives in the parsetree only to carry the result of the rewrite.- (The inverted bigint sign in this encoding was fixed in #8732.)
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 @inlined at call sites does nothing
It's parsed into ap_inlined and then ignored.
2.6 @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.7 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.
3. Related work
- #8689 (open): adds
@inline(crossModule)→Cross_module_inline.- The body is exported to
.cmjregardless of the flag (it must be closed, not async, and have no directive) and is always inlined at call sites. - Rewatch also rebuilds dependents when the
.cmjdigest changes. - Applied to
Option.map,flatMap,getOr, etc. in the stdlib.
- The body is exported to
- #8624 (parsetree v1): lists "
@inlineconstants: give them a structural form". - #8732 (merged): fixed the inverted bigint sign in inline constants.
4. Proposals
-
Fix stale dependents independently of #8689. Rebuild dependents when the
.cmjdigest changes, which is the rewatch part of #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.- The parser produces it for
@inline(lit) let x: tand for@inline let x(: t)? = lit. - The type checker types the literal and builds
Kind_inline_const. - The v0 bridge turns it back into the attribute for existing PPXs.
- This removes the frontend rewrite and fixes the bigint, type-annotation and dropped-attribute problems.
-
Separate the meanings of
@inline. Possible shape:@inlineon a literal binding = signature constant (A);@inline/@inline(never)on a function = optimization hint (C);- cross-module opt-in (D) = #8689's
@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". The bool/null/undefined constants that are always exported could stay, since proposal 1 makes them safe. -
Remove or implement
@inlined. It's currently parsed and ignored. Either delete it (with a deprecation warning) or makeok_to_inline_fun_when_apprespectap_inlined. -
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
- Should cross-module inlining of functions be opt-in per function only, or also available as a project-wide setting?
- Is rebuilding dependents on any
.cmjchange acceptable? #8689 notes that unrelated.cmjchanges also trigger rebuilds, and stored Lambda bodies include locations. - Should closed
@inlinefunctors keep their special export path?
- 主要言語
- OCaml
- スター
- 7.5k
- フォーク
- 485
- 平均マージ
- 21時間 51分
- マージ済み PR(30日)
- 53
環境構築
このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
rescript-lang/rescript のほかの issue
-
難易度 5/5 1週間以上 初心者へのやさしさ 35/100
rescript-lang/rescript#8727 ·
メンテナーはふだん 1 日以内に返信
-
難易度 5/5 1週間以上 初心者へのやさしさ 35/100
rescript-lang/rescript#8726 ·
メンテナーはふだん 1 日以内に返信
-
Integer range patterns ending at 2147483647 generate incorrect JavaScript対応中かも @fhammerschmidt が 5 日前に担当しました。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 70/100
rescript-lang/rescript#8716 ·
メンテナーはふだん 1 日以内に返信
-
Integer range patterns on record fields match values below the range対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 76/100
rescript-lang/rescript#8713 ·
メンテナーはふだん 1 日以内に返信
-
A regex literal that starts a statement is parsed as division, and the formatter removes the parentheses that prevent it対応中かも @fhammerschmidt が 5 日前に担当しました。 オープン
難易度 4/5 3〜5日 初心者へのやさしさ 68/100
rescript-lang/rescript#8688 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
rescript-lang/rescript の issue をすべて見る
似ている issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
Algorithmiq/monoprop#390 ·
メンテナーはふだん 1 日以内に返信
-
good first issue help wanted
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
-
powershell triage
難易度 1/5 1時間未満 初心者へのやさしさ 88/100
rjmurillo/moq.analyzers#1383 · コメント 1 件 ·
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
brainglobe/brainglobe-atlasapi#1004 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
enhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
IAmTomShaw/f1-race-replay#341 ·