Skip to content

Read type nodes through accessors (POC for an int-indexed type arena) - #9221

Draft
JonoPrest wants to merge 3 commits into
masterfrom
jono/type-expr-accessors
Draft

JonoPrest wants to merge 3 commits into
masterfrom
jono/type-expr-accessors

Conversation

@JonoPrest

@JonoPrest JonoPrest commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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 mutable type_expr records ({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):

1 domain 4 domains 7 domains large retained graph
int arena, mainline OCaml 5.5.2 0.66x 0.56x 0.54x 0.45x

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

  1. Accessors on mainline: all reads through get_desc/get_level/get_id, logged writes through the Btype setters and Transient_expr for writes that are undone locally or already logged, then make type_expr abstract. This is what upstream OCaml did in 4.14. Identical output, no speed change.
  2. A request-local int arena behind the accessors, starting with instance and unify, with boxed types exported at the request boundary (published signatures, the .cmi cache, analysis).
  3. If the arena pays off, a .cmi/.cmt encoder from the arena that keeps today's bytes (a prototype reproduced Marshal'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:

  • Types gets get_desc, get_level, get_id and Transient_expr (create, and unlogged set_desc/set_level for Btype's trail, copy sessions and marking, and for the parts of Ctype that restore their own temporary changes or log first). A check when Types loads 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.ml and ctype.ml are fully converted: no direct .desc/.level/.id access 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_desc does not call repr, unlike upstream. Callers still call repr first, so path compression and its trail entries happen exactly where they did. Folding repr into get_desc can be a later step.
  • get_desc, get_level and set_desc are %field0/%field1/%setfield0 primitives, 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, so set_level is a plain function.

Proof that it changes nothing and costs nothing (OCaml 5.5.2, this branch vs master 227b69776 built the same way):

  • Clean builds of the three projects and their dependencies: JS, .cmi, .cmj, .ast byte-identical at default domains and at one domain; .cmt semantically equivalent, meaning editor analysis (hover, inlay hints, document symbols), rescript-tools doc and reanalyze give identical output on every file (master's own .cmt bytes vary from run to run on 5.5.2).
  • make test, make test-rewatch and the rewatch unit tests pass.
  • Paired clean builds, 15 pairs each, median B/A with bootstrap 95% CI:
project domains user CPU wall
React app default 0.977 [0.936, 1.015] 0.954 [0.939, 0.990]
backend service default 0.998 [0.972, 1.041] 1.019 [0.968, 1.053]
library default 1.033 [0.967, 1.050] 1.000 [0.987, 1.013]
React app 1 1.009 [0.982, 1.019] 1.012 [0.984, 1.029]
backend service 1 0.950 [0.938, 0.993] 0.955 [0.938, 0.998]
library 1 0.996 [0.980, 1.023] 0.989 [0.968, 1.020]

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 inline get_id as 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 of analysis/, 6 in 2 files of gentype. Before this PR it was 565 (btype.ml 49, ctype.ml 203).

file sites
compiler/ml/typecore.ml 63
compiler/ml/printtyp.ml 35
compiler/ml/error_message_utils.ml 33
analysis/src/type_utils.ml 28
compiler/ml/typedecl.ml 23
compiler/ml/typetexp.ml 21
compiler/ml/subst.ml 20
compiler/ml/parmatch.ml 9
compiler/ml/datarepr.ml 7
analysis/src/completion_back_end.ml 7
compiler/ml/includecore.ml 6
compiler/ml/typeopt.ml 5
analysis/src/completion_jsx.ml 5
compiler/ml/translcore.ml 4
compiler/ml/record_type_spread.ml 4
compiler/gentype/translate_type_expr_from_types.ml 4
analysis/src/process_extra.ml 4
analysis/src/create_interface.ml 4
analysis/reanalyze/src/dead_value.ml 4
14 other files 27

17 of these are raw desc/level writes (in subst.ml, typetexp.ml, typedecl.ml, datarepr.ml, typecore.ml, printtyp.ml) to move behind the setters. Converting btype.ml and ctype.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

  • Is there appetite for this direction (stage 1 now, an arena prototype for instance/unify after)?
  • Should type_expr become abstract like upstream OCaml, with get_desc calling repr as upstream does, or should repr stay explicit?
  • Must .cmi/.cmt stay 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?

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Changelog

🏠 Internal

  • Read and update type nodes in Btype and Ctype through accessors, a first step towards an abstract type_expr.

Semantic Diff

3 commits · Full page

Add accessors for type nodes

be63491 · JonoPrest · Nov 04, 2026, 15:01 UTC

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.
Read and write type nodes through the accessors in Btype

9eb6866 · JonoPrest · Nov 04, 2026, 15:01 UTC

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.
Read and write type nodes through the accessors in Ctype

75d9763 · JonoPrest · Nov 04, 2026, 15:01 UTC

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.

@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.54545% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.77%. Comparing base (227b697) to head (75d9763).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
compiler/ml/ctype.ml 79.14% 44 Missing ⚠️
compiler/ml/btype.ml 94.73% 3 Missing ⚠️
compiler/ml/types.ml 85.71% 1 Missing ⚠️
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     
Files with missing lines Coverage Δ
compiler/ml/types.ml 74.41% <85.71%> (+0.73%) ⬆️
compiler/ml/btype.ml 90.78% <94.73%> (+0.41%) ⬆️
compiler/ml/ctype.ml 70.55% <79.14%> (+0.55%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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>
@JonoPrest
JonoPrest force-pushed the jono/type-expr-accessors branch from 75de1bf to 75d9763 Compare November 4, 2026 17:53
@pkg-pr-new

pkg-pr-new Bot commented Nov 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@9221

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@9221

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@9221

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@9221

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@9221

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@9221

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@9221

commit: 75d9763

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant