Semantic Diff

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

4 commits

6741bab07e473f7dbec3e56efc6a807dee8eea28..pr-9192

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

Copy a dependency's signature only when it is read

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.
80c9bb5ac · JonoPrest · Nov 03, 2026, 08:53 UTC

Read the signatures in reverse order in the deferred copy test

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.

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

Test the counter restore and the fallback of deferred signature copies

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.

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

Build on OCaml 5.0 and test unread copies with runtime interfaces

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.