Skip to content

Replace term tuples with term vectors to reduce compilation latency - #354

Open
matthieugomez wants to merge 1 commit into
JuliaStats:masterfrom
matthieugomez:vector-terms
Open

Replace term tuples with term vectors to reduce compilation latency#354
matthieugomez wants to merge 1 commit into
JuliaStats:masterfrom
matthieugomez:vector-terms

Conversation

@matthieugomez

@matthieugomez matthieugomez commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fitting a model with a never-seen formula currently costs ~0.1–0.8s of compilation, even when every term type is already precompiled. The reason is that formulas are encoded as tuples, so apply_schema, modelcols, coefnames, etc. get a fresh specialization for every distinct number and order of terms, and likewise every method taking a MatrixTerm{Ts<:TupleTerm}.

This PR represents term collections as Vector{AbstractTerm} instead of tuples of terms (or a single term when there was only one variable in the formula). This is a breaking change (0.8.0).
Changes

  • + on terms returns Vector{AbstractTerm}. ~ (and @formula) always makes the rhs a vector, even for a single term (@formula(y ~ x).rhs == [term(:x)]), removing the lone-term special case. The lhs stays a bare term, and after apply_schema the rhs still collapses to a bare MatrixTerm, so y, X = modelcols(f, data) is unchanged.
  • InteractionTerm and MatrixTerm are no longer parametric and store a Vector{AbstractTerm}. collect_matrix_terms returns a vector in the mixed matrix/non-matrix case.
  • apply_schema over a vector loops term by term, with the same left-to-right FullRank semantics as the tuple broadcast.
  • TupleTerm is removed; TermOrTerms is now Union{AbstractTerm, AbstractVector{<:AbstractTerm}}.
  • Content-based == and hash for FormulaTerm, InteractionTerm, and MatrixTerm, since vector fields break the default === fallback. This also fixes formula equality after apply_schema (a former @test_broken).
  • Version bumped to 0.8.0.

Latency

Timings below are for the first modelmatrix call on a formula shape not seen before, in a warm session on Julia 1.12, after warming up the pipeline with y ~ 1 + a and y ~ 1 + a + e:

d = (y=rand(1000), a=rand(1000), b=rand(1000), c=rand(1000), e=repeat(["u","v"], 500))
# each line is a formula shape never seen before
@time modelmatrix(@formula(y ~ 1 + a + b), d);
@time modelmatrix(@formula(y ~ 1 + a + b + c), d);
@time modelmatrix(@formula(y ~ 1 + a + b + e), d);
@time modelmatrix(@formula(y ~ 1 + a + b + c + a&b), d);

StatsModels 0.7.10: every new shape recompiles the pipeline (~99.9% compilation time):

0.062946 seconds (272.38 k allocations: 13.183 MiB, 99.71% compilation time)
0.101134 seconds (621.89 k allocations: 30.417 MiB, 99.77% compilation time)
0.071856 seconds (345.43 k allocations: 16.959 MiB, 99.60% compilation time)
0.753103 seconds (3.40 M allocations: 168.672 MiB, 99.92% compilation time)

This PR:

0.000062 seconds (134 allocations: 53.391 KiB)
0.000066 seconds (175 allocations: 70.906 KiB)
0.013173 seconds (75.77 k allocations: 3.913 MiB, 98.90% compilation time)
0.374247 seconds (1.59 M allocations: 79.114 MiB, 99.89% compilation time)

Downstream impact

I ran the test suites of the main reverse dependencies against this branch.

  • Pass unchanged: GLM, StandardizedPredictors.
  • Fail to load because TupleTerm is gone (fix is a one-line signature swap to AbstractVector{<:AbstractTerm} per method): RegressionFormulae, MixedModels, RCall, AnovaBase, HurdleDMR, MixedModelsSerialization.
  • Runtime failures from code that special-cases a tuple rhs (or isa MatrixTerm{Tuple{...}} checks), each a few lines: Econometrics, FixedEffectModels, ShiftShares, Metida, SurvivalAnalysis.

The only substantive migration is in MixedModels: the InteractionTerm{<:NTuple{N,CategoricalTerm}} dispatch (randomeffectsterm.jl:127,210) has to become a runtime check (all(t -> t isa CategoricalTerm, it.terms)), since interaction element types no longer live in the type domain.

- Represent term collections as Vector{AbstractTerm}: + returns a vector,
  InteractionTerm and MatrixTerm store Vector{AbstractTerm}, and the rhs
  built by ~ / @formula is always a vector (a lone term is wrapped)
- Apply schemas by looping over the vector, so methods compile once per
  term type instead of once per formula shape
- Define content-based == and hash for FormulaTerm, InteractionTerm, and
  MatrixTerm (vector fields break the egal fallback)
- Remove TupleTerm; TermOrTerms is now Union{AbstractTerm, AbstractVector{<:AbstractTerm}}
- Update tests and doctests; bump version to 0.8.0
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