Semantic Diff

#9193 Skip unneeded work in occur checks and primitive specialization

3 commits

e941d27298f5f7dcbc9a42cce0d5d7b349d27f7d..pr-9193

393d6cb16 · JonoPrest · Nov 03, 2026, 09:27 UTC

Try a cheap bounded walk before the full occur checks

Why

Every variable link in unification runs occur, and the Tvar/Tconstr cases also run deep_occur. occur builds an AVL set of visited nodes along every path and deep_occur marks every node and walks the type again to unmark it, although almost all of these walks see a handful of nodes. On a 552 module React app they make 943k and 494k calls, about 0.5 s of 14 s single domain CPU.

Both checks now first walk the type with no visited set and no marks, giving up after 256 nodes (which also stops cycles and heavy sharing); when the walk gives up, the unchanged full walk runs. - occur in expression mode: a walk over the edges occur_rec follows that finishes without meeting the variable proves occur_rec would neither raise nor expand anything. Pattern mode, which looks types up, always runs the full walk. - deep_occur: the walk only answers "absent". It gives up on meeting the variable and on a node an enclosing walk already marked, so the marks left behind are the same as the full walk's.

Review

  • Can an answer, a diagnostic or an artifact change? No: each fast walk either proves the full walk would do nothing more than it did, or gives up and the full walk runs from scratch. The new fixtures pin the errors, including one past the budget, and the corpus (5,398 artifacts and 1,147 .cmt/.cmti) is byte identical to master.
58959d92f · JonoPrest · Nov 03, 2026, 09:27 UTC

Skip scraping a primitive's type when no specialization reads it

Why

specialize_primitive scraped the primitive's whole function type (a deep copy plus head expansion) on every use, but only unified operators and comparisons look at it. For the other 95% of uses (on a 552 module React app, 21,582 of 22,770) the result was thrown away and find_builtin answered.

For a function type whose primitive is in neither table, it now calls find_builtin directly.

Review

  • Could the skipped scrape have had an effect? Scraping a function type looks nothing up: only a Tconstr head is expanded. What is left is a copy with fresh type ids, made during Lambda translation after the .cmi and .cmt are written, whose result nothing read. The corpus artifacts and .cmt files are byte identical to master.
dfc6baaf1 · JonoPrest · Nov 09, 2026, 13:10 UTC

Build the occur walk's closure once per call

Why

occur_reaches passed occur_reaches budget ty0 to iter_type_expr at every node, so the fast walk allocated a partial application per node it visited.

occur_reaches now defines its walk once per call, capturing the budget and the variable, as deep_occur_fast_absent already does. The edges it follows and the exceptions it raises are unchanged.

Measured with scripts/compilerBenchmarks.js against the previous commit: allocated words -1.58% in total, stress/TypeAliasChain.res -53%, Belt and runtime files -2% to -2.7%. On a 552 module React app (clean build, one domain) the build allocates 0.05% fewer words. A version that returns a bool instead of raising allocated more than this one (-1.27% in total), because List.for_all takes a closure per list.