Repository navigation
Conversation
Changelog🏠 Internal
Semantic Diff3 commits · Full page Add accessors for type nodesbe63491 · JonoPrest · Nov 04, 2026, 15:01 UTC The type checker reads and writes the fields of
Review
Read and write type nodes through the accessors in Btype9eb6866 · JonoPrest · Nov 04, 2026, 15:01 UTC Btype now uses Review
Read and write type nodes through the accessors in Ctype75d9763 · JonoPrest · Nov 04, 2026, 15:01 UTC Ctype, the core of unification, instantiation, generalization and the occur checks, now reads type nodes only through Review
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #9221 +/- ##
==========================================
+ Coverage 81.75% 81.77% +0.01%
==========================================
Files 457 457
Lines 65332 65409 +77
==========================================
+ Hits 53413 53485 +72
- Misses 11919 11924 +5
🚀 New features to boost your workflow:
|
The type checker reads and writes the fields of `type_expr` directly in about 1,000 places, which ties it to the boxed mutable record. Upstream OCaml (4.14 and later) made `type_expr` abstract behind `get_desc`, `get_level`, `get_id` and `Transient_expr`; this adds the same layer while the record stays visible, so modules can be converted one at a time and `type_expr` made abstract once none reads the fields. `get_desc` returns the node's own descriptor and does not call `repr`, so callers keep calling `repr` first and path compression stays where it is. `get_desc`, `get_level` and the raw setters are field primitives, which compile to the same loads and stores as field access, also in dune's dev profile, which builds with `-opaque`. Review: Does this change the representation or any output? No. The record and its field order are unchanged; only the way code reaches the fields is new. Signed-off-by: Jono Prest <jjprest@gmail.com>
Btype now uses `get_desc`, `get_level` and `get_id` for every read of a type
node and `Transient_expr` for creation and its unlogged writes (path
compression, the backtracking trail, copy sessions, marking). Record
patterns such as `{desc = Tvar _}` become matches on `get_desc`.
Review: Do `repr`'s path compression and its trail entries change? No.
`repr_link` takes the same arguments, compresses the same nodes and logs
the same `Ccompress` entries.
Signed-off-by: Jono Prest <jjprest@gmail.com>
Ctype, the core of unification, instantiation, generalization and the occur checks, now reads type nodes only through `get_desc`, `get_level` and `get_id`, and makes its unlogged writes (copy marks, level marks, temporary links, writes after `log_type`) through `Transient_expr`. Its local `get_level env p`, the binding level of a path, is renamed `get_path_level` so that it does not shadow `Types.get_level`. Review: Does the conversion change evaluation order, sharing or allocation? No. Writes still evaluate the new descriptor before storing it, `filter_visited` returns the same list cell, and the already-copied variant case in `copy` matches with a guard instead of building an option. Signed-off-by: Jono Prest <jjprest@gmail.com>
75de1bf to
75d9763
Compare
rescript
@rescript/belt
@rescript/darwin-arm64
@rescript/linux-arm64
@rescript/linux-x64
@rescript/runtime
@rescript/win32-x64
commit: |
|
Developer playground preview: https://rescript-lang.github.io/rescript/dev-playground/?version=pr-9221 |
Draft, proof of concept for discussion. Not meant to merge as is.
Why
After the recent performance PRs, rescript-rust is still about 1.4 to 1.75x faster on clean builds at default domains and 1.2 to 1.6x on one domain (React app, 552 modules; backend service, 391 modules; library). On the React app at one domain, roughly half of the remaining gap is artifact I/O that rescript-rust skips (it never writes
.cmt/.cmi/.cmj/.ast), and about a third (≈1.5 s) is memory management. Most of that GC time is the type graph: OCaml's Runtime_events GC counters show 1.2 s of the 1.5 s minor GC time is promoting the remembered set, because unification keeps writing young nodes into old mutabletype_exprrecords ({mutable desc; mutable level; id}). At default domains the same promotion work shows up as stop-the-world minor GC time.rescript-rust avoids this by keeping type nodes in an arena addressed by integers and freeing it per compilation.
What a model says
I wrote a microbenchmark of the type checker's hot loop (instance, unify with path compression and occur check, update_level, generalize, snapshots and backtracking) over today's boxed representation and over an int-indexed struct-of-arrays arena, same algorithm, same checksum:
Time of the int arena relative to today's representation in that microbenchmark (lower is better):
Promoted words drop 30 to 60x, minor GC time 17 to 70x, stop-the-world time at 7 domains from 4.0 to 0.15 domain-seconds. This needs nothing beyond mainline OCaml. OxCaml layouts (packed
int8#/int32#columns, unboxed records, stack-allocated views) added nothing measurable on top, and erasing them for mainline made that code up to 1.5x slower than today's representation, so OxCaml is optional for this, not needed.Caveats: the model covers only the type graph (no paths, Env, typedtree, lambda, JS or
.cmt), so the real gain is capped by the type graph's share of GC time (≈1.5 s of 11.6 s on the React app at one domain, plus its share of stop-the-world time at default domains), not by the ratios above. Nothing here has been measured in the real compiler yet.Staged plan
get_desc/get_level/get_id, logged writes through theBtypesetters andTransient_exprfor writes that are undone locally or already logged, then maketype_exprabstract. This is what upstream OCaml did in 4.14. Identical output, no speed change..cmicache, analysis)..cmi/.cmtencoder from the arena that keeps today's bytes (a prototype reproducedMarshal's bytes for a node DAG; matching every sharing decision of today's heap is the hard part).What this PR does
Stage 1 for the type checker's core, as a sample of what the full refactor looks like:
Typesgetsget_desc,get_level,get_idandTransient_expr(create, and unloggedset_desc/set_levelforBtype's trail, copy sessions and marking, and for the parts ofCtypethat restore their own temporary changes or log first). A check whenTypesloads fails if the record's field order ever stops matching the primitives. The record stays visible, so unconverted modules keep compiling; making it abstract is the end state of stage 1.btype.mlandctype.mlare fully converted: no direct.desc/.level/.idaccess or{desc = ...}pattern is left (checked by compiling both with a local record type that shadows the fields, so any direct access fails to type check).get_descdoes not callrepr, unlike upstream. Callers still callreprfirst, so path compression and its trail entries happen exactly where they did. Foldingreprintoget_desccan be a later step.get_desc,get_levelandset_descare%field0/%field1/%setfield0primitives, so they compile to the same loads and stores as field access, also in the dev profile, which builds with-opaque. OCaml 5.0 has no%setfield1, soset_levelis a plain function.Proof that it changes nothing and costs nothing (OCaml 5.5.2, this branch vs master
227b69776built the same way):.cmi,.cmj,.astbyte-identical at default domains and at one domain;.cmtsemantically equivalent, meaning editor analysis (hover, inlay hints, document symbols),rescript-tools docandreanalyzegive identical output on every file (master's own.cmtbytes vary from run to run on 5.5.2).make test,make test-rewatchand the rewatch unit tests pass.No regression is detectable at this resolution (about 5%). I don't claim a gain from the intervals below 1.0; they are noise or code layout. These are dev-profile builds (
make), where nothing is inlined across modules; release builds inlineget_idas well.What remains for stage 1
Direct uses of type node fields (reads, record patterns, writes) after this PR (regex count, about ±10%): 313 sites in 33 files: 245 in 20 files of
compiler/ml, 62 in 11 files ofanalysis/, 6 in 2 files of gentype. Before this PR it was 565 (btype.ml49,ctype.ml203).compiler/ml/typecore.mlcompiler/ml/printtyp.mlcompiler/ml/error_message_utils.mlanalysis/src/type_utils.mlcompiler/ml/typedecl.mlcompiler/ml/typetexp.mlcompiler/ml/subst.mlcompiler/ml/parmatch.mlcompiler/ml/datarepr.mlanalysis/src/completion_back_end.mlcompiler/ml/includecore.mlcompiler/ml/typeopt.mlanalysis/src/completion_jsx.mlcompiler/ml/translcore.mlcompiler/ml/record_type_spread.mlcompiler/gentype/translate_type_expr_from_types.mlanalysis/src/process_extra.mlanalysis/src/create_interface.mlanalysis/reanalyze/src/dead_value.ml17 of these are raw
desc/levelwrites (insubst.ml,typetexp.ml,typedecl.ml,datarepr.ml,typecore.ml,printtyp.ml) to move behind the setters. Convertingbtype.mlandctype.ml(252 sites) took a few hours with a scripted rewrite plus hand conversion of record patterns, so the rest of stage 1 looks like a few days of mechanical work plus review.Questions
type_exprbecome abstract like upstream OCaml, withget_desccallingrepras upstream does, or shouldreprstay explicit?.cmi/.cmtstay byte-compatible with today's, or would a deliberate format change be acceptable if stage 2 needs it?analysis/and gentype read type internals directly (about 70 sites). Is it fine to move them to the accessors too, or should tools get a separate boxed view of types?