Skip to content

Copy a dependency's interface only when it is read - #9192

Open
JonoPrest wants to merge 4 commits into
masterfrom
jono/defer-signature-copy
Open

JonoPrest wants to merge 4 commits into
masterfrom
jono/defer-signature-copy

Conversation

@JonoPrest

Copy link
Copy Markdown
Contributor

When a module refers to a dependency, Env.normalize_path checks whether it is a module alias, and that forces the dependency's ps_sig: a full Subst copy of the interface. Almost none of those copies are read afterwards. On a 552 module React app these copies take about 5% of build CPU. With this change 2,921 of them are deferred, and only 10 of those are ever read.

This defers the copy without changing any output:

  • A compilation unit is never an alias, so normalize_path now only loads the .cmi (imports and CRC checks still happen) and reserves the identifier stamps and type ids that the copy would take, by advancing both counters.
  • The first real read (find_module, fold_modules, read_signature) makes the copy with the counters set back to the reserved bases. Every stamp and type id is then the same as with an eager copy, so .cmi, .cmj, JS and .cmt stay byte identical.
  • The number of stamps and ids a copy takes depends on the shape of the type graph, so it is recorded from the first eager copy of the same interface contents, keyed by the CRC written in the .cmi. The first copy of each interface in a process (and a one shot bsc) is still made eagerly.
  • If a copy ever takes a different count than reserved, it is discarded and made again with fresh ids. This has not happened on any project.

It is independent of #8874, which caches reading the .cmi files rather than copying them.

Clean builds, 8 vCPU Linux VM, this PR / master, 5 runs:

project default domains wall / CPU 1 domain wall / CPU
React app, 552 modules 0.91 / 0.91 0.93 / 0.94
backend service, 391 modules ~1.0 / 0.96 0.94 / 0.94
library 0.95 / ~1.0 0.96 / 0.94

Testing:

  • New ounit_deferred_signature_tests.ml copies every runtime and Belt .cmi eagerly and deferred, creating identifiers and type variables between reservations and reading the signatures in reverse order, and compares the marshalled results byte for byte. It also covers interfaces that are reserved but never read, counter restore on exceptions, and the fallback for a wrong count.
  • belt_deferred_signature_copy_test.res covers alias chains, functors, module type of, include and recursive modules over dependency types, and three super_errors fixtures print dependency types through aliases.
  • JS, .cmi, .cmj, .ast, .cmt and .cmti are byte identical to master across three production codebases and their dependencies at 1, 16 and the default number of domains. make test and make test-rewatch pass.

JonoPrest added a commit that referenced this pull request Nov 3, 2026
The entry names #9192 and is filed under polish like the other build
speedups.

Signed-off-by: Jono Prest <jjprest@gmail.com>
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Changelog

💅 Polish

  • Build: copy a dependency's interface only when it is read, after the first copy of the same interface in a build, instead of every time a module checks whether the dependency is an alias.

Semantic Diff

4 commits · Full page

Copy a dependency's signature only when it is read

e710627 · JonoPrest · Nov 03, 2026, 08:47 UTC

Why

Env keeps each loaded compilation unit's signature as a lazy copy of the .cmi signature with its binders renamed (Subst.signature). normalize_path forced that copy just to learn that a compilation unit is not a module alias, which it never is; normalize_path is reached for every value reference such as Belt.Array.map, for abstract dependency types in Ctype.try_expand_once, and from Includemod and Printtyp. These copies were about 4-6 % of a clean build's CPU time on real projects (ui: 0.62 s of 13.3 s with one domain).

normalize_path now only looks the unit up (keeping the .cmi load, import and CRC checks) and reserves the stamps and type ids the copy would take, so every later identifier and type node gets the same stamp and id as before. The copy is made with exactly those stamps and ids when the signature is first read (find_module, scrape_alias, fold_modules, read_signature); on a 552-module app 2,921 copies are reserved and 10 are ever made.

How many stamps and ids a copy takes is learnt from the first copy of the same .cmi contents, keyed by the CRC create_cmi wrote, and kept for the process. If a reserved copy ever takes a different number, it is thrown away and made again with fresh stamps and ids, so a wrong count can change ids but never makes two nodes share one.

Review

  • Are the artifacts the same? Yes: stamps and type ids are the same by construction. JS, .cmi, .cmj, .ast and .cmt are byte-identical to master on the 5,398-artifact corpus (default and one domain), and a unit test checks that deferred copies of every runtime and Belt interface marshal to the same bytes as eager ones, with the same stamps and ids created around them.
  • Can the copy differ when it is made later? Only if the .cmi graph changed in between; the type checker only works on copies of it, and closed object types' shared mutability cells, the one part it can change, do not change what is copied.
Read the signatures in reverse order in the deferred copy test

80c9bb5 · JonoPrest · Nov 03, 2026, 08:53 UTC

List.rev_map applies its function from the head, so the test read the signatures in the order they were reserved; it now reads the last one first, so each copy is made with stamps and ids reserved before others.

Test the counter restore and the fallback of deferred signature copies

a8323b2 · JonoPrest · Nov 03, 2026, 09:02 UTC

The independent review found the env.mli comment for read_signature attached to the new stats function, and the comment and changelog overstating when copies are deferred: only after an earlier copy of the same interface in the process, never for the first one or a one-shot bsc. New unit tests check that Ident.with_current_time puts the counter back when its function raises, and that a deferred copy whose counts are wrong (a forged .cmi CRC) is made again with fresh stamps.

Build on OCaml 5.0 and test unread copies with runtime interfaces

eb53533 · JonoPrest · Nov 03, 2026, 09:23 UTC

Mutex.protect only exists from OCaml 5.1, so the count table locks with Mutex.lock and Fun.protect like Process_child.with_lock. The unread copy test asked for Belt modules, which the coverage job doesn't build; it now uses runtime interfaces only.

@JonoPrest

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T09:42:47.107983Z 5695c85 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: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 120bb652f6

ℹ️ 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 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.09202% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.76%. Comparing base (6741bab) to head (eb53533).

Files with missing lines Patch % Lines
compiler/ml/env.ml 93.44% 4 Missing ⚠️
...ests/ounit_tests/ounit_deferred_signature_tests.ml 95.91% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9192      +/-   ##
==========================================
+ Coverage   81.73%   81.76%   +0.03%     
==========================================
  Files         458      459       +1     
  Lines       65292    65448     +156     
==========================================
+ Hits        53367    53516     +149     
- Misses      11925    11932       +7     
Files with missing lines Coverage Δ
compiler/ext/ident.ml 91.22% <100.00%> (+0.31%) ⬆️
tests/ounit_tests/ounit_tests_main.ml 100.00% <ø> (ø)
compiler/ml/env.ml 80.28% <93.44%> (+0.83%) ⬆️
...ests/ounit_tests/ounit_deferred_signature_tests.ml 95.91% <95.91%> (ø)
🚀 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.

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: eb53533

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

@JonoPrest

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 5695c85e05

ℹ️ 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".

@JonoPrest
JonoPrest marked this pull request as ready for review November 3, 2026 09:43
@JonoPrest
JonoPrest requested a review from cknitt November 3, 2026 09:43
@JonoPrest
JonoPrest added this pull request to stack #9195 November 3, 2026 11:38
@JonoPrest
JonoPrest force-pushed the jono/defer-signature-copy branch from 5695c85 to 467b151 Compare November 3, 2026 19:51
Why:
    Env keeps each loaded compilation unit's signature as a lazy copy of the
    .cmi signature with its binders renamed (Subst.signature). normalize_path
    forced that copy just to learn that a compilation unit is not a module
    alias, which it never is; normalize_path is reached for every value
    reference such as Belt.Array.map, for abstract dependency types in
    Ctype.try_expand_once, and from Includemod and Printtyp. These copies were
    about 4-6 % of a clean build's CPU time on real projects (ui: 0.62 s of
    13.3 s with one domain).

normalize_path now only looks the unit up (keeping the .cmi load, import and
CRC checks) and reserves the stamps and type ids the copy would take, so
every later identifier and type node gets the same stamp and id as before.
The copy is made with exactly those stamps and ids when the signature is
first read (find_module, scrape_alias, fold_modules, read_signature); on a
552-module app 2,921 copies are reserved and 10 are ever made.

How many stamps and ids a copy takes is learnt from the first copy of the
same .cmi contents, keyed by the CRC create_cmi wrote, and kept for the
process. If a reserved copy ever takes a different number, it is thrown away
and made again with fresh stamps and ids, so a wrong count can change ids but
never makes two nodes share one.

Review: Are the artifacts the same? Yes: stamps and type ids are the same by
construction. JS, .cmi, .cmj, .ast and .cmt are byte-identical to master on
the 5,398-artifact corpus (default and one domain), and a unit test checks
that deferred copies of every runtime and Belt interface marshal to the same
bytes as eager ones, with the same stamps and ids created around them.
Review: Can the copy differ when it is made later? Only if the .cmi graph
changed in between; the type checker only works on copies of it, and closed
object types' shared mutability cells, the one part it can change, do not
change what is copied.

Signed-off-by: Jono Prest <jjprest@gmail.com>
List.rev_map applies its function from the head, so the test read the
signatures in the order they were reserved; it now reads the last one
first, so each copy is made with stamps and ids reserved before others.

Signed-off-by: Jono Prest <jjprest@gmail.com>
The independent review found the env.mli comment for read_signature
attached to the new stats function, and the comment and changelog
overstating when copies are deferred: only after an earlier copy of the
same interface in the process, never for the first one or a one-shot bsc.
New unit tests check that Ident.with_current_time puts the counter back
when its function raises, and that a deferred copy whose counts are wrong
(a forged .cmi CRC) is made again with fresh stamps.

Signed-off-by: Jono Prest <jjprest@gmail.com>
Mutex.protect only exists from OCaml 5.1, so the count table locks with
Mutex.lock and Fun.protect like Process_child.with_lock. The unread copy
test asked for Belt modules, which the coverage job doesn't build; it now
uses runtime interfaces only.

Signed-off-by: Jono Prest <jjprest@gmail.com>
@JonoPrest
JonoPrest force-pushed the jono/defer-signature-copy branch from 467b151 to eb53533 Compare November 3, 2026 21:51

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