Skip to content

Memoize srcTypeToVar in solver for monomorphic types#95

Draft
CharlonTank wants to merge 1 commit intolamdera:lamdera-nextfrom
CharlonTank:perf/typecheck-srctype-memoize
Draft

Memoize srcTypeToVar in solver for monomorphic types#95
CharlonTank wants to merge 1 commit intolamdera:lamdera-nextfrom
CharlonTank:perf/typecheck-srctype-memoize

Conversation

@CharlonTank
Copy link
Copy Markdown
Contributor

@CharlonTank CharlonTank commented Apr 16, 2026

Summary

Type checking modules that import functions with large monomorphic type aliases is dominated by redundant calls to srcTypeToVar in Type.Solve. This change adds a per-run cache keyed on (rank, Can.Type) that returns the previously-built Variable when the same monomorphic type is encountered again.

Problem

When srcTypeToVar processes Can.TAlias home name args (Filled tipe), it walks the entire expanded tipe and allocates a fresh UnionFind Variable for every node. For a function whose signature references a record alias with N transitively-expanded fields, each call site of that function makes srcTypeToVar re-walk those N nodes and create N fresh variables.

Instrumentation on a project with 391 modules and a few large model record aliases:

  • 45,326,616 srcTypeToVar calls
  • 4,006 unique Can.Type subtrees
  • Single hottest type called 33,788 times

The 11,000x redundancy comes entirely from re-walking the same Filled expansions across different constraints.

Fix

A per-run IORef (Map (Int, Can.Type) Variable) cache is threaded through solve and srcTypeToVariable/srcTypeToVar. Cache lookups happen only when flexVars is empty (truly monomorphic context) — this guard ensures correctness in the presence of polymorphism:

  • For a polymorphic call site (forall a. a -> a), each instantiation needs fresh variables for a
  • For a monomorphic type, the resulting Variable depends only on (rank, Can.Type) and can safely be shared between call sites

The cache is local to each run invocation, so there is no cross-module sharing or thread safety concern.

Results

On a project with 391 modules (cold build of test suite, +RTS -N12, median of 3 runs):

Metric Before After Improvement
Cold build 120s 98s -18%
typecheck time of bottleneck module 125s 67s -46%
Variance 117-131s 97-100s tighter

The relative improvement scales with how heavily a project relies on large monomorphic type aliases. Apps that follow patterns like a single big Model record exposed across many helpers benefit most.

Safety considerations

  • Polymorphism: cache is bypassed when flexVars is non-empty, so polymorphic instantiation is unaffected
  • Generalization (CLet): cache key includes rank, so variables created at one rank are never reused at another
  • Mutation through UnionFind: shared Variables are correct for monomorphic types — unifying a flex variable with a concrete shared Variable constrains the flex side; the shared side's descriptor matches everywhere it appears
  • Per-run scope: cache is local to each Type.Solve.run; no cross-module state

Test plan

  • Cold build of large project succeeds
  • Project's elm-test-rs suite passes (70 tests)
  • Multiple compiler test scenarios (scenario-alltypes, scenario-empty-lamdera-init, direct-fn-calls, direct-fn-calls-mutual-recursion) compile correctly
  • App build (Frontend + Backend) unchanged on warm cache (~0.15s)

When a function signature like FA -> Action references large monomorphic
type aliases (e.g. FrontendModel with 100+ transitive fields), srcTypeToVar
walks the entire expanded type and creates fresh UnionFind variables on every
single call site. On a real project, this resulted in 45M+ srcTypeToVar
calls for only ~4000 unique Can.Type subtrees (11000x redundancy).

This commit adds a per-run cache keyed on (rank, Can.Type) that returns the
previously-built Variable when the same monomorphic type is encountered again.
The cache is gated on flexVars being empty to ensure correctness in the
presence of polymorphism: when type variables are in scope, sharing would
incorrectly conflate distinct instantiations.

On a real Lamdera project (391 modules, including dense Effect.Test code):
  - Cold build: 120s -> 98s (-18%, median over 3 runs)
  - typecheck UsersFlows: 125s -> 67s (-46%) on the bottleneck module
@CharlonTank CharlonTank marked this pull request as draft April 17, 2026 02:56
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