Skip to content

Write every model as a recipe; all seventeen compile into one shared library - #16

Merged
sshin23 merged 7 commits into
mainfrom
nabla/recipes
Aug 14, 2026
Merged

Write every model as a recipe; all seventeen compile into one shared library#16
sshin23 merged 7 commits into
mainfrom
nabla/recipes

Conversation

@sshin23

@sshin23 sshin23 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Every model is written as a recipe — <name>_recipe (structure, sizes open),
<name>_args (the integers that close it), <name>_model (the two composed).
All seventeen compile ahead of time, individually and into one shared library
(16.2 MB, 0 verifier errors), every model exact against its in-Julia
counterpart at sizes the library was never compiled at — measured locally,
not in CI
; no committed test compiles a library, so these numbers are
re-established only by re-running the local harness against the pinned
ExaModels.

Fourteen models instantiate from one integer; bearing/minsurf/torsion
from two through the builder surface; elec's seed is baked into the recipe
via Base.Fix2 on a named function, so one structure serves every seed.

Nothing about the models changes

All twenty harness cases — the suite's own comparison sizes plus the PDE
family — are identical to main's constructors on dimensions, bounds,
starting point, minimize, objective, gradient, constraints, and the Jacobian
and Hessian including COO order (measured locally with a mutation-armed
harness; the in-CI testset is the recipe testset below plus the pre-existing
JuMP comparison). At Float32 the eleven T-dependent models are identical to
main in structure and values — the itau index maps are computed in T
exactly as on main.

The two rules AOT compilability adds

Data derived from a size is reached from the recipe by a deferred call to a
named, package-owned function (the rule is stated in the extension entry
file): d = ArgCall(COPSBenchmark.gasoil_data, (Val(T), nh)), with *_args
returning only the sizes. T rides as Val{T} — a bare ::Type{T} stores as
an abstract DataType field and the deferred call stays dynamic under
--trim=safe. And because those functions run inside the compiled library,
comprehensions need concrete element types — T[...] under a where {T}
specialization is static; T[...] with a keyword T is not.

Known regression (not this PR's)

The #308 branch tip currently fails --trim=safe on cores carrying a deferred
collect over a product iterator with a symbolic factor — every 2-D COPS
model. Verified against an earlier ExaModels lineage with identical
model-facing content: compiles and matches exactly. The test pin tracks the
#308 branch (repoint condition in the pin's comment); compile-adjacent CI legs
stay red until the branch fix lands.

Requires madsuite-org/ExaModels.jl#308.

sshin23 and others added 7 commits August 12, 2026 18:43
Each converted problem gains `<name>_recipe` — its structure, with the size
left open — and `<name>_args`, the values that close it; `<name>_model`
becomes the two composed, so there is one definition rather than two that can
drift.  Every model is unchanged: at the sizes the test suite itself uses for
callback comparison, all ten agree with the previous constructors on
dimensions, bounds, starting point, `minimize`, objective, gradient,
constraints, and the Jacobian and Hessian including COO order.

Converted: camshape, rocket, steering, channel, gasoil, catmix, methanol,
marine, pinene, chain.

What the size no longer being a number required:

  * Scalars derived from it stay in the recipe as deferred expressions --
    `h = tf/nh`, `d_theta = 2pi/(5(n+1))` -- including under exponentiation
    (`h^(k-1)`) and in bounds (`lcon = -alpha*d_theta`).  Written `one(T)/nh`
    rather than `T(1)/T(nh)`: a constructor applied to a placeholder fails
    where arithmetic defers.
  * Uniform bounds and starts written as arrays sized by nh become scalars.
    catmix's `lvar = zeros(T, nh, nc)` is `zero(T)`, which says the same thing
    without depending on the size at all.
  * Genuinely patterned data -- comprehensions over the size -- moves to
    `<name>_args`, bundled as one named tuple rather than many arguments, so
    the recipe reads `d.con1_itr` where the original read `con1_itr` and the
    diff stays reviewable through dense collocation arithmetic.
  * Rows indexing the last point become generators over `nh:nh`; where a fixed
    set rides along, a two-variable generator; where coefficients ride along,
    the index travels in the tuple with them (channel's bc3/bc4).

methanol keeps scalar starts on purpose: it builds `v0` through three loops and
then overwrites it wholesale with 0.001, so nothing size-dependent survives.

robot and glider are NOT converted.  Both use `@add_expr`, which stores its body
as a closure; inside a model-building function that closure captures the
`Variable`s, whose types carry the placeholder, and `instantiate` cannot reach
inside a closure to resolve them.  ExaModels refuses such a core rather than
returning a half-instantiated model.  Converting them would need either that
fixed upstream or the expressions inlined by hand, which would change the
sparsity pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bearing, torsion, minsurf

All seventeen problems the suite compares callbacks for are now split into
`<name>_recipe` (structure, size open) and `<name>_args` (the values that
close it), with `<name>_model` the two composed.  Every one is identical to
the previous constructor at the sizes the suite itself uses, on dimensions,
bounds, starting point, `minimize`, objective, gradient, constraints, and the
Jacobian and Hessian including COO order.

Notes on the ones that were not mechanical:

  * elec draws its starting distribution from a seed.  That is data: the draw
    and the seed both live in `elec_args`, so the recipe holds no randomness
    and one structure serves every seed.
  * robot and glider used `@add_expr`, whose body is stored as a closure that
    captures the `Variable`s -- placeholders end up in the closure's type,
    where `instantiate` cannot reach them.  Since `@add_expr` adds no variables
    and no constraints, only a name, a local helper splices the identical tree
    at each point of use.  glider's eight subexpressions chain through each
    other exactly as before.
  * bearing, torsion and minsurf take two sizes, so they carry three
    placeholders: the two sizes and the data.
  * minsurf's interior block is indexed by `Int(floor(0.25/hx))`; a constructor
    applied to a placeholder fails, so those ranges are data -- the iteration
    set itself, not merely the values in it.

Offloading moves values, not arithmetic.  bearing first folded `e*sin(x)` into
one coefficient, which re-associates `(-hx*hy*e)*sin(x)` and moved twelve
gradient entries by one ulp; `sin(x)` alone is passed and `e` stays a factor in
the expression.  A tolerance-based comparison would not have shown it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving derived data into `_args` kept the models identical and made all but
one of them uncompilable: `P_new` instantiates from integers, so a recipe
whose arguments carry tables is refused.  The data now stays where the
recipe can reach it -- a deferred call to a named package function,
`d = ArgNode1(COPSBenchmark.foo_data, nh)` (ArgNode2 for the two-size
models), with `_args` returning only the sizes.  `_recipe`/`_args`/`_model`
keep their meaning; only where the data is computed moves.

Because those functions now run inside the compiled library, they inherit
`--trim=safe`'s constraints, and two idioms that are fine under the JIT do
not survive it:

  * `T[...]` where `T` is the keyword argument is a runtime-typed
    comprehension -- `_array_for(T::Type, ...)` cannot resolve.  Twenty
    sites now use a concrete element type; the core converts on append.
  * elec's seed is baked via `Base.Fix2(elec_data, seed)`, keeping every
    type in the core named and making the library deterministic per seed.

robot and glider return to `@add_expr`, which stores a node rather than a
closure as of the ExaModels change this branch builds against.

All 20 harness cases remain identical to main's constructors.  Compiled:
each model individually, then all seventeen into one shared library
(16.2 MB, 0 verifier errors), every model exact against its in-Julia
counterpart at sizes the library was never compiled at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checks what `*_model` cannot show now that it is defined as the composition:
the core declares the arity its `*_args` supplies and its variable count is
an expression rather than a number; and it is not consumed by being used —
one core instantiates at two sizes and again at the first, each time equal
to what `*_model` builds at that size on dimensions, starting point,
objective and gradient.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hmark

Sungho's ruling on the Float32 finding: the `*_data` functions regain main's
T-generic semantics rather than declaring Float64 canonical.  T travels as a
leading `::Val{T}` argument carried inside the deferred node — a bare
`::Type{T}` stores as an abstract `DataType` field and the deferred call
stays dynamic under `--trim=safe`; the `Val` singleton carries the type in
its own type, and the where-clause specialization makes the `T[...]`
comprehensions static.  The `itau` integer index maps are computed in T
again, exactly as on main: measured identical to main at Float32 across all
eleven T-threaded models, structure and values, and identical at Float64
across all twenty harness cases.  Data functions whose main-branch bodies
were Float64 arithmetic (bearing, chain, elec, minsurf, polygon) keep
size-only signatures — threading T through them would invent a dependency
main never had.

Style parity with LuksanVlcekBenchmark: `*_args` signatures carry only the
sizes they consume (T flows through the recipe; elec's seed is baked there);
the deferred-callable rule is stated in the extension entry file in the same
wording; the test pin moves to madsuite-org's #308 branch with its repoint
condition in the comment.

Known regression, not ours: the #308 tip currently fails `--trim=safe` on
cores carrying a deferred collect over a product iterator with a symbolic
factor — every 2-D COPS model.  Verified against an earlier ExaModels
lineage with identical model-facing content: gasoil compiles and matches
exactly at sizes the library never saw.  CI on this PR stays red on the
compile-adjacent legs until the branch fix lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compile_all is a method on ExaModelsCompiler's generic function, implemented
here in an extension triggered by ExaModelsCompiler as a weakdep -- so a
benchmark package never acquires a compiler toolchain by being loaded.

The seventeen problems are derived from the package's own surface via the
*_recipe names rather than hand-listed, so a model added to src/ is compiled
without editing the extension.  Instantiation spec belongs to the model: the
three grid problems take (nx, ny) and the rest a single size.  only/exclude go
through ExaModelsCompiler.select, which validates names against what the
package actually provides.

The test pin names a SHA rather than a branch: a branch rev is not reproducible
under julia-actions/cache, since the job restores ~/.julia from an earlier run
and can reuse that clone.

Verified against ExaModels main a9fbf9cc: all 17 models compile into one
library (16.07 MB, 0 verifier errors); 16 of 17 read back exact at sizes never
compiled for.  steering fails with 'steering_block returned nonzero status 2',
an upstream metadata fault tracked on the ExaModels side, not a model defect --
the model itself builds and solves.
compile_all had no test at all: the provider's whole AOT surface -- which
problems it offers, what each is closed with, and the selection contract --
was resting on manual runs.

Most of it is testable without invoking a compiler, because select validates
names before compile_library is reached, so the error paths exercise
compile_all itself rather than a stand-in and cost nothing:

  - the derived problem list covers the package.  The extension builds its
    models from the *_recipe names rather than a hand-written list precisely so
    it cannot drift as models are added; this is what makes that a fact rather
    than an intention.
  - every (recipe, args...) pair compile_all would hand the compiler closes
    into a model.  This is what breaks when a recipe and its *_args disagree.
  - an unknown name is refused, rather than silently yielding a library missing
    the model the caller asked for; so is a selection that excludes everything,
    and so is a package that implements no method.

Then one real compile of a single small model, because a list that assembles is
not evidence that anything in it compiles.  Bounded deliberately: if it proves
too slow for the runner it belongs in a separate job, and CI is what tells us.
@sshin23
sshin23 merged commit 5381b9f into main Aug 14, 2026
2 checks passed
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