Model recipes and ExaModelsC - #294
Merged
Merged
Conversation
An ExaCore can now be written against `arg` placeholders — sizes, starting
points, bounds and generator iterators left open — and resolved later by
`ExaModel(core, argvals)`. `arg` mirrors `DataSource` one level up: a sentinel
whose field access encodes the path in a type parameter, resolved once at
instantiation rather than per kernel iteration.
c, x = add_var(c, arg.N; start = arg.v)
c, _ = add_obj(c, (x[i] - 1)^2 for i in 1:arg.N)
m = ExaModel(c, (N = 1000, v = ones(1000)))
`instantiate` is the identity on anything carrying no dependency, so a core
built without `arg` is unaffected; `ExaModel(core)` and `ExaModel(core,
nothing)` are the same path as before. The deferral choice is made by dispatch
on a `Val` rather than by a predicate over values, keeping the build path
inferable — `juliac --trim=safe` needs the whole call graph resolved, and the
AOT leg passes unchanged.
To carry deferred values, the counters and vectors of `ExaCore`, the offsets of
`SIMDFunction`, and the lengths of `Variable`/`Parameter`/`Expression` each take
their own type parameter; all of them resolve to `Int` and to the backend's
vector type exactly as before. Parameters are appended, so existing partial
parameterizations still match. `T` is now passed explicitly at construction: it
used to be recovered from the `VT <: AbstractVector{T}` bound, and is still
taken from the realised `x0` so backends that promote (Metal, Float64 → Float32)
keep their element type.
Because `instantiate` is the identity on types it has no method for, a field it
cannot walk would otherwise pass through with placeholders intact and produce a
model that looks built. `ExaModel(core, argvals)` therefore asks the
instantiated core's type whether any placeholder survives, and reports it.
The check is selected by dispatch on `check::Val` so it stays trimmable.
Also adds ExaModelsC, a subdirectory package compiling such a core into a
shared library through JuliaC, exposing the model over the C ABI consumed by
CNLPModels.jl. The core travels as data and is deserialized at the generated
package's precompile time, so no model-building code enters the compiled call
graph; the example `arg` fixes the types trimming needs while the size stays a
runtime value. Verified against an in-Julia model: compiled at N = 4 and
exercised at 6, 11, 20 and 25, agreeing on metadata, every evaluation callback,
both sparsity structures, and the Ipopt solution.
Structured instantiation (the schema + builder ABI) is not implemented yet;
an example argument it cannot carry is refused with a reason.
Follows the review of the previous commit.
`ExaCore(nargs = Val(N))` hands back the core followed by N placeholders, and
`ExaModel(core, args...)` consumes one concrete value per placeholder in that
order. A placeholder is used *as the value it stands for* — `N` is the number
of variables, not a namespace to reach into:
core, N, x0 = ExaCore(concrete = Val(true), nargs = Val(2))
@add_var(core, x, N; start = x0)
ExaModel(core, 10, zeros(10))
`Val(0)` is the default and returns the core alone, so nothing changes for
models that do not use this. `ntuple` over a `Val` keeps the arity in the type,
so how many values come back is known statically and destructuring stays
inferable. The pre-created `arg` singleton is gone; a placeholder is something
you ask the core for.
An argument scalar can now appear inside an expression — `h * x[i]` where
`h = 1/(N+1)` — as an `ArgLeaf`. That borrows `DataIndexed`'s handling without
being the same idea: a `DataIndexed` is what an algebraic pattern is
*parameterized by*, complete and evaluated once per data point, while an
`ArgLeaf` is a placeholder that is substituted once and gone. What they share
is only that neither has a value while sparsity is being discovered, and
structure does not depend on values. Also supported: a symbolic variable index
(`x[N]`), deferred `collect` for generators and for products of symbolic
ranges.
Broadcasting over a placeholder is refused with a message saying to compute the
array in the function that builds the arguments. It was implementable, and had
no consumer; the explicit refusal matters because Base's `broadcastable`
fallback would otherwise wrap the node in a `collect` and fail later as
something unrecognisable.
A core still holding placeholders, handed to the one-argument `ExaModel`, used
to fail inside `NLPModelMeta` with a nested node type in the message. It now
says it is a recipe and what to pass.
ExaModelsC gains a `bundle` option, defaulting to a single ~2 MB library
linked against the installed Julia. That form works from Python and C but
aborts inside a Julia process: the `@ccallable` preamble adopts the calling
thread, and a thread already adopted by the host runtime aborts in
`jl_init_threadtls`. `bundle = true` carries a privatized runtime and is the
form CNLPModels.jl can load. A bare name for the output resolves against
CNLPMODELS_PATH, in the layout the consumers already look for.
Documentation: a Literate page covering recipes end to end — why they exist
(AOT compilation requires separating structure from data, and which constructs
survive trimming is not visible while writing a model), writing one,
instantiating it, compiling it, and consuming the result from CNLPModels.jl or
cnlpmodels. Its examples execute at docs build.
Testing: the compiled library is checked from both consumers against an
in-Julia model — metadata, every callback, both sparsity structures, and the
Ipopt solution — and a CI job runs ExaModelsC on self-hosted Linux and macOS
and on windows-latest, mirroring where the AOT test runs and skipping GPU.
`compile_library` keeps the `bundle` option and now defaults it to `true`. A juliac library links libjulia, and `--trim` reduces the compiled code rather than the runtime. Loaded into a process that is already Julia, a library sharing the host's libjulia aborts on its first call: the `@ccallable` preamble adopts the calling thread, and `jl_init_threadtls` guards that with `if (jl_get_pgcstack() != NULL) abort();`. `jl_get_pgcstack` reads *that runtime's* thread-local storage, so a privatized runtime — a distinct one whose `pgcstack` is NULL — adopts legitimately while a shared one aborts. Measured rather than assumed, in three forms: linking against the installed Julia aborts, bundling *without* privatizing aborts too (the standard soname means the loader reuses the host's copy and never opens the bundled one), and only the privatized bundle works. So privatizing is the part that matters and bundling is merely how it is delivered — a renamed soname cannot be produced without copying the runtime. `bundle = false` stays, for Python and C callers, where the calling thread genuinely is foreign and 2 MB beats 80. The proper fix is upstream: the generated preamble should skip the adoption when the thread is already a Julia thread.
Contributor
|
Your PR requires formatting changes to meet the project's style guidelines. Please run: julia --project=@runic -e 'using Pkg; Pkg.add("Runic")'
julia --project=@runic -e "using Runic; exit(Runic.main(ARGS))" -- --fix <files>(or Note: the full diff is omitted because it can exceed GitHub Actions input limits. |
`convert` sits in the loop that gives the type-directed operations a deferred
form, so `AbstractArgNode` acquired `convert(::Type{T}, ::AbstractArgNode)`.
Base uses that name for two unrelated things, and the second one is storage:
`convert(::Type{Any}, x)` runs whenever a value is put into an `Any` slot, and
on 1.10 `push!` to a `Vector{Any}` goes through it. Neither method is more
specific, so a node reaching a `Vector{Any}` raised an ambiguous `MethodError`
rather than being stored.
On 1.12 that `push!` does not call `convert` at all, which is why the failure
was confined to the LTS jobs — `ArgumentTest` errored there on every platform
while the same commit was green on 1.12.
Argument tests on 1.10.11: 146 passed with 1 error before, 156 passing after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Member
Author
|
@pulsipher fyi. With this PR, ExaModels allows creating a "recipe", which you can compile a |
juliac implements runtime privatization for Linux and macOS only; on Windows `privatize_libjulia!` warns "not implemented for this OS" and does nothing. The library still builds there — CI produced a 115 MB bundle — but it keeps the standard libjulia soname, so the loader hands it the host's already-loaded copy and the @ccallable preamble aborts in jl_init_threadtls adopting a thread the host already owns. That happens whatever `bundle` is set to. So the leg is dropped rather than papered over with a skip: a guard for a platform never exercised is untestable code. The limitation is stated in `compile_library`'s docstring, where someone compiling on Windows will meet it. The rest of the suite still runs on windows-latest; only this job is affected.
Contributor
Benchmark Results |
`cuda13` matches no runner, so both the `gpu` matrix leg and `bench-cuda` sat queued and never started — visible on PR #294 as jobs pending indefinitely while amdgpu and oneapi finished. The runners are labelled `cuda`. Three places, all the same label: the gpu matrix, the CUDA.jl setup step keyed on it, and bench-cuda's runs-on.
The docs build asks for `cuda13`, which matches no runner — the same label the gpu and bench jobs were using. That is why `build` sat pending on #294 while every other job reported.
`compile_library(core, out; arg = (N = 10,))` spelled the same thing two ways: instantiating a recipe is `ExaModel(core, 1000)`, compiling it was a keyword holding a NamedTuple. Now it is `compile_library(out, core, 1000)` — path first, example values variadic and positional, exactly as the model constructor takes them. The example values are also read properly now. A schema is derived from their types — scalar, array, or table, one field per placeholder — in the shape both consumers already parse, rather than the old rule that an example had to be an Integer or a one-integer NamedTuple. A value that cannot cross the boundary is refused by type, and a recipe that needs the builder interface reports the schema it would publish.
`actions/setup-python` assumes the hosted layout: it writes to `/Users/runner`, which does not exist on a self-hosted Mac, so the macos leg died with `mkdir: /Users/runner: Permission denied` and then exit 127 — infrastructure, not the package. Both examodelsc legs are self-hosted, so the action has no business there. The runners have their own Python, and the test already probes for one with numpy and skips that leg when it finds none, which is the behaviour the skip was written for.
The docs build fails on two cross-references it cannot resolve: `Identity` and `Variable`. Neither has a doc entry — one is an internal marker, the other a handle users hold but that is not documented — so they are plain code spans until they are. The `Variable` one is not new: it is on main. The docs job has been asking for a `cuda13` runner that does not exist, so it queued rather than ran, and the failure was invisible. Fixing that label is what surfaced it.
CNLPModels.jl and cnlpmodels-py now spell instantiation the way this side spells it: CNLPModel(lib, arg1, ...) and CModel(lib, arg1, ...) rather than an args keyword. Nothing here changes but the call sites and the prose describing them — compile_library already took its example values positionally, and the schema it derives already names its fields arg1, arg2, ..., which is what the consumers now bind against. The note above _schema said a consumer binds a named tuple by those names. That is no longer offered on either side: _field refuses a NamedTuple as a field value, so there was never a producer-side counterpart to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both conflicts are the same shape: main pinned every self-hosted job to the `examodels` label so the organization's shared runners stop taking this repository's work, and this branch had already renamed `cuda13` to `cuda`. Main's side is a superset of ours in each case, so it is taken whole. This branch adds one self-hosted job main does not have — `examodelsc`, whose runner list comes from the matrix rather than from `runs-on` — so the merge would have left it unpinned. Its linux leg ran on a shared runner earlier today and failed there, like everything else scheduled onto one. Both legs now ask for `examodels` too.
main removed LegacyExaCore (#295), which collides with this branch in three places. `src/deprecated.jl` is gone. Everything this branch added to it was about the type main deleted — the `Val{false}` override that made `ExaCore()` return the legacy wrapper, and the guard refusing `arg` placeholders on it — so the deletion is taken whole and nothing moves elsewhere. The testset exercising that guard goes with it; it asserted `ExaCore()` warns and returns a `LegacyExaCore`, and neither is true any more. `src/nlp.jl` is the substantive one: both sides rewrote the `ExaCore` constructors. Main's side cannot be taken as written, because `_exa_core` now takes its float type positionally and main still calls it with keywords only. The resolution keeps this branch's constructors — which match that signature, and carry `nargs`/`_with_args` — and folds in main's `concrete` handling, so `Val(true)` remains an accepted no-op and `Val(false)` reports that the core it selected is gone. `_make_exacore` is dropped rather than merged: it existed solely to dispatch `concrete` onto `LegacyExaCore`.
sshin23
added a commit
to madsuite-org/madsuite-py
that referenced
this pull request
Aug 11, 2026
`ss/refactor` was the branch carrying model recipes and ExaModelsC. It merged as madsuite-org/ExaModels.jl#294, so the pin now names a branch that exists only until someone deletes it, and that has stopped moving either way. Content-neutral today: `main` and the `ss/refactor` tip differ in one docs file and nothing under `src/`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An
ExaCoreis normally built from data you already have, so the model and itsdata are finished together and cannot be separated afterwards. This adds a way
to write one against placeholders and supply the data later:
Such a core is called a recipe. It is not a new type — it is an ordinary
ExaCoreholding symbolic expressions in some of its slots instead of numbers.Why
The reason is ahead-of-time compilation. It needs the structure to become
code while the data stays a run-time input, and
juliac --trim=saferequiresthe whole call graph to resolve statically. Which modelling constructs survive
that is not something a model author can see while writing; the usual way to
find out is to compile, read a trimming error naming an inferred type somewhere
in the machinery, and guess.
Writing a model against placeholders is the separation compilation requires.
A recipe that builds and instantiates is on the compilation pathway, and the
author never reasons about trimming. One core being reusable at any size is a
consequence, not the motivation.
API
ExaCore(nargs = Val(N))returns the core followed byNplaceholders;ExaModel(core, args...)consumes one concrete value per placeholder, inorder.
nargs = Val(0)is the default and returns the core alone, so nothingchanges for models that do not use this.
ntupleover aValkeeps the arityin the type, so how many values come back is known statically and destructuring
stays inferable.
A placeholder is used as the value it stands for —
Nis the number ofvariables, not a namespace. Sizes, bounds, starting points, the collections
generators iterate, and loop-invariant scalars inside expressions (
h * x[i]with
h = 1/(N+1)) can all be deferred. Anything requiring arbitrary Julia — acomprehension over a deferred size, a random draw, a parsed table — is computed
by the caller and passed as one of the arguments.
Broadcasting over a placeholder is refused with a message saying to compute the
array in the function that builds the arguments. The explicit refusal matters:
Base's
broadcastablefallback would otherwise wrap the node in acollectand fail later as something unrecognisable.
Implementation
ArgSource{K}mirrorsDataSource's encoding — a sentinel whose field accesscarries the path in a type parameter — but the two are different ideas.
DataSourceis the parameterization of an algebraic pattern: the tree iscomplete, and the data point is bound afresh each kernel iteration.
ArgSourceis a placeholder, substituted once and gone; nothing evaluates it.To carry deferred values,
ExaCore's counters and vectors,SIMDFunction'soffsets, and the lengths of
Variable/Parameter/Expressioneach take a typeparameter. All resolve to
Intand the backend's vector type exactly as before,and the new parameters are appended so existing partial parameterizations still
match.
Tis now passed explicitly at construction: it used to be recoveredfrom the
VT <: AbstractVector{T}bound, and is still taken from the realisedx0, so backends that promote (Metal, Float64 to Float32) keep their elementtype.
The deferral choice is made by dispatch on a
Valrather than a predicate overvalues, keeping the build path inferable —
--trim=safeneeds the call graphresolved, and the AOT test leg passes unchanged.
Because
instantiateis the identity on types it has no method for, a field itcannot walk would pass through with placeholders intact and produce a model
that looks built.
ExaModel(core, args...)therefore asks the instantiatedcore's type whether any placeholder survives, and reports it. The check is
selected by dispatch on
check::Valso it stays trimmable.ExaModelsC
A subdirectory package compiling a recipe into a shared library through JuliaC,
exposing it over the plain C interface consumed by
CNLPModels.jl and
cnlpmodels:
The example values are given exactly as they would be to
ExaModel(core, ...)— compiling and instantiating a recipe should not be spelled two different
ways — and a schema is derived from their types, one field per placeholder.
The core travels as data and is deserialized at the generated package's
precompile time, so no model-building code enters the compiled call graph. The
example fixes the types trimming needs while the size stays a runtime value. A bare name for the output resolves against
CNLPMODELS_PATH, in alayout both consumers already look for. Both consumers then take their
arguments the same way —
CNLPModel("rosenrock", 1000)in Julia,cnlpmodels.CModel("rosenrock", 1000)in Python — so one spelling runs fromthe recipe through the compiler to the model.
bundle = trueis the default and carries a privatized copy of the Juliaruntime. That is not merely about self-containment: a library sharing the
host's
libjuliaaborts on its first call from Julia, because the@ccallablepreamble adopts the calling thread and
jl_init_threadtlsguards adoption withif (jl_get_pgcstack() != NULL) abort();.jl_get_pgcstackreads thatruntime's thread-local storage, so a privatized runtime — a distinct one whose
pgcstackis NULL — adopts legitimately. Linking against the installed Juliaand bundling without privatizing both reproduce the abort.
bundle = falsegives a single ~2 MB library for Python and C callers, where the calling thread
genuinely is foreign.
Testing
both the deterministic and the symbolic build path.
ExaModel— metadata, every callback, both sparsity structures, and theIpopt solution. Compiled at N = 4 and exercised at 6, 11, 20 and 25, so a
size baked in rather than deferred would fail.
windows-latest, mirroring where the AOT test runs; not on GPU runners.
Not included
data-defined models such as OPF). An example argument it cannot carry is
refused with a reason rather than compiled into a library that would fail to
load.
add_exprrecipes:Expressionstores the generator closure, which does notserialize, so such a core cannot be compiled.
per build; a stable salt would let many models share one copy.
OpenBLAS_jll.__init__becauseNLPModelsdepends onLinearAlgebra.Nothing in the evaluation kernels calls BLAS.
🤖 Generated with Claude Code