Skip to content

Look up the modules of a namespace by name instead of listing them all - #9287

Open
JonoPrest wants to merge 1 commit into
masterfrom
jono/lazy-namespace
Open

JonoPrest wants to merge 1 commit into
masterfrom
jono/lazy-namespace

Conversation

@JonoPrest

@JonoPrest JonoPrest commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Every module of a package with a namespace opens the namespace's module, the map from each module of the package to its compiled name (module Price = Price-Shop). Opening it built its components eagerly: for every alias, a substitution entry, two table insertions, a lazy value and an environment binding, all kept alive for the rest of the compile. That is work proportional to the size of the package for every module, so a clean build is quadratic in the number of modules. On a namespaced package of about 4,900 modules, a frame-pointer profile put 46% of the build's CPU samples under Env.components_of_module_maker for the namespace module.

This change builds the components of a signature made only of aliases to persistent modules (a namespace map, and also modules such as Belt that only re-export units) on demand. Tbl gets a third kind of table, Tbl.of_source, whose entries are computed per name when they are looked up or visited, with folds and iterations in key order. The components index the signature once: a sorted array of names and a table from name to item and position. While the build file store is enabled, each domain also keeps the indexes of the 16 most recently used such interfaces, keyed by the physical signature that Bs_cmi_load returns to every compile of a build, and drops them when the store's generation changes, the same lifetime as the decoded interfaces. bsc and compiles outside a build index the signature each time.

Measured with clean builds on a shared 8-core Linux machine, 15 interleaved pairs, median B/A with bootstrap 95% CI:

project domains before after wall user CPU
about 4,900 modules, namespace default 40.4 s 8.70 s 0.211 [0.204, 0.226] 0.229 [0.225, 0.231]
about 4,900 modules, namespace 1 146.2 s 38.4 s 0.264 [0.261, 0.268] 0.226 [0.224, 0.227]
480 modules, namespace default 1.09 s 1.02 s 0.931 [0.914, 0.951] 0.894 [0.872, 0.915]
480 modules, namespace 1 6.35 s 5.83 s 0.947 [0.898, 0.990] 0.880 [0.842, 0.904]
about 4,900 modules, no namespace default 6.03 s 6.16 s 1.026 [0.975, 1.060] 1.012 [0.995, 1.029]
about 4,900 modules, no namespace 1 31.9 s 32.0 s 0.997 [0.948, 1.011] 1.004 [0.980, 1.017]

Peak RSS of the large namespaced build drops from 2.5 GB to 1.2 GB at default domains. scripts/compilerBenchmarks.js is neutral (allocated words -0.01% in total, no benchmark changed by 0.1% or more; the output-size differences come from the two checkouts' path lengths).

Output is unchanged. Every artifact of an 11-project benchmark corpus (88,523 files: JavaScript, GenType, .cmi, .cmj, .ast, .cmt/.cmti, the namespaced packages and their dependencies), and of the runtime and Belt, is byte-identical to master's, with one domain and with the default number. A private corpus of three applications is identical as well (JavaScript, .cmi, .cmj, .ast at both settings, .cmt at one domain; with default domains master's own .cmt files differ between runs, and the editor analysis and rescript-tools doc outputs over every source file are identical).

The new build test tests/build_tests/namespace_components covers lookups through the namespace, an alias and a local open of a namespaced module, a module named like the namespace, a local module that hides a module of the package, a "did you mean" hint, whose candidates follow the order of the namespace's modules, and a type printed in an error. The hint fails when folds skip the lazy table or visit it unsorted.

What reviewers may want to check:

  • The equivalence argument in the comment above is_alias_map: an alias to a persistent module mentions no other item, so leaving out the other items' substitution entries and the environment extended item by item can't change what a component resolves to.
  • Sharing. A first version used the looked-up name in the component's path and the incoming identity substitution; outputs were then equal in value but marshalled differently, because strings and module declarations were shared where the eager loop had copies. Each entry now uses the item's own name and a substitution holding its own entry, as the eager loop did (comment in alias_map_components).
  • The cache's lifetime and key in interface_alias_map_index.
  • That no other Tbl user can see the new constructor: Tbl.t is abstract, and only the namespace components create one. add on such a table builds the whole tree (nothing adds to these tables today).

Checklist

  • Added a changelog fragment (see changes/README.md), or no release notes are needed and a maintainer should apply changelog:skip.
  • Added tests for the fix or feature.
  • Gave every commit a description below its subject, saying why it is made (see "Commit messages" in CONTRIBUTING.md).
  • Signed off every commit for the DCO (git commit -s).

@JonoPrest

Copy link
Copy Markdown
Contributor Author

@codex review

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Changelog

💅 Polish

  • Compile packages with a namespace faster: each module no longer walks every entry of the namespace when it opens it, which made a clean build of a large namespaced package quadratic in its number of modules (4.6 times faster on a package of about 4,900 modules).

Semantic Diff

1 commit · Full page

Look up the modules of a namespace by name instead of listing them all

18dd323 · JonoPrest · Nov 09, 2026, 15:23 UTC

Why

Every module of a package with a namespace opens the namespace's module, which maps each module of the package to its compiled name (module Price = Price-Shop). Opening a module builds its components, and building them walked every item of its signature, with a substitution entry, two table insertions and an environment binding per item, all kept for the rest of the compile. Each module of a namespaced package therefore did work proportional to the size of the package, and a clean build did quadratic work: on a package of about 4,900 modules, 46% of the build's CPU time went to building these components.

The components of a signature made only of aliases to persistent modules, such as a namespace's, are now looked up per name: Tbl.of_source makes a table whose entries are computed when a name is first looked up or visited, from an index of the signature's items. Folds and iterations visit the names in the order of the table the eager loop built, which decides, for example, the order of the candidates in a "did you mean" hint. The entries are the ones the eager loop made: an alias to a persistent module mentions no other item of its signature, so the substitution and the environment that the loop extended item by item can't change what it resolves to. Each entry still gets its own path and a substitution with its own item, so the declarations it returns are copies, shared as before in the .cmi and .cmt files. While the build system keeps decoded interfaces across the compiles of a build, each domain also keeps the indexes of the 16 most recently used such interfaces, by physical signature, until the build's files change.

Build modified (clean builds, default domains, medians of 15 interleaved pairs on a shared 8-core machine):

text

-4,900 modules, namespace    wall 40.4 s   user 169.6 s   peak RSS 2,536 MB
-  480 modules, namespace    wall  1.09 s  user   3.45 s
-4,900 modules, none         wall  6.03 s  user  29.3 s
+4,900 modules, namespace    wall  8.70 s  user  38.7 s    peak RSS 1,180 MB
+  480 modules, namespace    wall  1.02 s  user   3.09 s
+4,900 modules, none         wall  6.16 s  user  29.8 s (no change: 95% CIs include 1)

Every artifact of the 11 projects of a benchmark corpus (88,523 files, .cmt files included), of the runtime and of Belt is byte-identical to master's, with one domain and with the default number.

Review

  • Do error messages and editor tools see the same modules in the same order? Lookups, folds and iterations over the table return what the eager table held, folds in the order of its keys. The new build test checks the "did you mean" hint, which lists the namespace's modules in that order, the type printed in an error, a module named like the namespace and a local module that hides one of the package.
  • How long are the indexes kept? Only while the build file store is enabled, one per decoded interface: a domain keeps the 16 most recently used and drops them when the store's generation changes, as it drops its decoded interfaces. bsc and other compiles outside a build index the signature on each compile.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T16:53:19.328790Z 87d7812 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 87d781276e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@codecov

codecov Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.38462% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.93%. Comparing base (b82d944) to head (18dd323).

Files with missing lines Patch % Lines
compiler/ml/tbl.ml 44.44% 5 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master    #9287   +/-   ##
=======================================
  Coverage   81.92%   81.93%           
=======================================
  Files         456      456           
  Lines       65464    65515   +51     
=======================================
+ Hits        53631    53679   +48     
- Misses      11833    11836    +3     
Files with missing lines Coverage Δ
compiler/ml/env.ml 80.20% <100.00%> (+0.93%) ⬆️
compiler/ml/tbl.ml 88.37% <44.44%> (-11.63%) ⬇️

... and 2 files with indirect coverage changes

🚀 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.

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

✅ Compiler benchmarks

Allocated words

In total: 683,548,708 → 683,547,921 (±0.00%).

None of the 261 benchmarks changed by 0.1% or more.

Instructions

In total: 9,664,672,297 → 9,666,221,246 (+0.02%).

Benchmark Base Head Change
packages/@rescript/runtime/JsxDOM.res 120,169,630 120,314,426 +0.12%

Output size

In total: 25,569,598 → 25,571,598 (+0.01%).

Benchmark Base Head Change
output/playground/compiler.js 1,889,749 1,891,749 +0.11%

Pull request 18dd323 compared with base b82d944 in this run.

Why:
    Every module of a package with a namespace opens the namespace's
    module, which maps each module of the package to its compiled name
    (`module Price = Price-Shop`). Opening a module builds its components,
    and building them walked every item of its signature, with a
    substitution entry, two table insertions and an environment binding
    per item, all kept for the rest of the compile. Each module of a
    namespaced package therefore did work proportional to the size of the
    package, and a clean build did quadratic work: on a package of about
    4,900 modules, 46% of the build's CPU time went to building these
    components.

The components of a signature made only of aliases to persistent modules,
such as a namespace's, are now looked up per name: `Tbl.of_source` makes a
table whose entries are computed when a name is first looked up or
visited, from an index of the signature's items. Folds and iterations
visit the names in the order of the table the eager loop built, which
decides, for example, the order of the candidates in a "did you mean"
hint. The entries are the ones the eager loop made: an alias to a
persistent module mentions no other item of its signature, so the
substitution and the environment that the loop extended item by item
can't change what it resolves to. Each entry still gets its own path and
a substitution with its own item, so the declarations it returns are
copies, shared as before in the `.cmi` and `.cmt` files. While the build
system keeps decoded interfaces across the compiles of a build, each
domain also keeps the indexes of the 16 most recently used such
interfaces, by physical signature, until the build's files change.

Build modified (clean builds, default domains, medians of 15 interleaved
pairs on a shared 8-core machine):
Before (text):
    4,900 modules, namespace    wall 40.4 s   user 169.6 s   peak RSS 2,536 MB
      480 modules, namespace    wall  1.09 s  user   3.45 s
    4,900 modules, none         wall  6.03 s  user  29.3 s
After (text):
    4,900 modules, namespace    wall  8.70 s  user  38.7 s    peak RSS 1,180 MB
      480 modules, namespace    wall  1.02 s  user   3.09 s
    4,900 modules, none         wall  6.16 s  user  29.8 s (no change: 95% CIs include 1)

Every artifact of the 11 projects of a benchmark corpus (88,523 files,
`.cmt` files included), of the runtime and of Belt is byte-identical to
master's, with one domain and with the default number.

Review: Do error messages and editor tools see the same modules in the
same order? Lookups, folds and iterations over the table return what the
eager table held, folds in the order of its keys. The new build test
checks the "did you mean" hint, which lists the namespace's modules in
that order, the type printed in an error, a module named like the
namespace and a local module that hides one of the package.

Review: How long are the indexes kept? Only while the build file store is
enabled, one per decoded interface: a domain keeps the 16 most recently used and drops
them when the store's generation changes, as it drops its decoded
interfaces. `bsc` and other compiles outside a build index the signature
on each compile.

Signed-off-by: Jono Prest <[email protected]>
@JonoPrest
JonoPrest force-pushed the jono/lazy-namespace branch from 87d7812 to 18dd323 Compare November 9, 2026 17:13
@pkg-pr-new

pkg-pr-new Bot commented Nov 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 18dd323

@JonoPrest
JonoPrest marked this pull request as ready for review November 9, 2026 17:27
@JonoPrest
JonoPrest requested a review from cknitt November 9, 2026 17:27
@github-actions

github-actions Bot commented Nov 9, 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