ExaModelsC: emit several models into one library - #308
Conversation
A shared library may carry any number of models, one symbol prefix each, and
the consumers already select among them. The producer emitted exactly one.
compile_library("@grid",
:acopf => (ac_core, 100), # acopf_* inside libgrid.so
:dcopf => (dc_core, 100), # dcopf_* in the same library
:fixed => small_core, # no instantiation data
)
Each model is a :name => core pair, or :name => (core, args...) to give the
example values ExaModel takes. The names become the prefixes, so prefix= has
no meaning in this form and is not accepted.
Internally the two entry points now share ModelSpec: _model_spec does the
per-model validation both need, and _compile the back half. Every piece of
per-model state in the generated module carries its prefix (CORE_p, ARG0_p,
MODELS_p, _valid_p), which is what lets the models coexist — separate cores,
separate instance tables, so each model's first instance is id 1 rather than
their sharing a counter.
The library FILE is named from out rather than from a prefix, since with
several models no one prefix can name it; that is also what a consumer
resolves @grid against. The generated MODULE name is sanitized instead of the
file name being restricted, so a dashed out still works here.
Models share the library's one privatized ~80 MB runtime, which is the reason
to co-package a family rather than emit a library each.
Also drops a false claim from the docstring: a NamedTuple holding one integer
field was documented as an accepted example value, and _field rejects it
outright. (_arg_expr(::Symbol) is dead code as a result; left alone.)
Per-model instantiation is unchanged — one integer, or none for a fixed
model. The schema + builder surface is still not emitted, for any model.
|
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. |
Every instantiate method takes its arguments as a bare vararg that is
only splatted through to child calls. Julia's passthrough heuristic
leaves such varargs unspecialized — harmless under the JIT, but the
map over a core's block tuples then carries a dynamic call that
juliac --trim=safe reports as unresolved (57 verifier errors on the
first multi-argument recipe ever compiled). Annotating every signature
with Vararg{Any,N} forces specialization on the concrete argument
types; behavior is unchanged. ArgumentTest: 158/158.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recipes whose examples are not a single integer — several values, floats, arrays, tables, or NamedTuples of these, exactly as ExaModel takes them — now compile to the ABI v2 builder surface both consumers already implement: P_schema publishes the flattened field list, P_data_begin / P_set_* / P_data_ready take the values by name, and P_new_from_data reassembles the ExaModel arguments. A NamedTuple example flattens into one schema field per key; bare values are named arg1, arg2, ... by position. Builder models export no P_new — the consumers rely on that disjointness to route a lone integer — and a one-key integer NamedTuple keeps the P_new fast path. Builder storage is generated concretely from the example types (one slot per scalar/array field and per table column), so --trim=safe sees no dynamic containers; examples must be Int64/Float64 exactly, refused otherwise with the reason. Probe failures now surface as ArgumentError naming the model instead of a raw MethodError. Suite: 192/192, including a compiled two-surface library (builder + one-knob in one file), consumption from CNLPModels.jl and cnlpmodels (Python driving the builder with a columnar table), and an Ipopt solve through a builder-instantiated model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Benchmark Results |
The generated app declared path sources only for the packages that own a type in a core. Every other dependency — including transitive ones — resolved from the registry, so the app could compile different code than the process that produced the core, and silently: the build succeeds, and the only trace is a path inside a stack frame. Measured: a one-line fix to a Pkg.develop-ed ExaPowerIO changed nothing across two builds because the app kept compiling the registry copy; with the path carried through, the same fix took the compile from four unresolved calls to none. Every package tracked by path in the caller's environment now becomes a [deps] + [sources] entry of the generated project — but not an import: a deps+sources entry pins the path on its own (verified), imports are only needed for the packages whose types the serialized cores carry, and importing everything a caller happens to be developing would drag unrelated packages into every app's compile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rarg #308's Vararg{Any,N} is necessary and I had missed it entirely — a vararg only splatted through is left unspecialized, so the map closure carries a dynamic call. It is not sufficient on a large core. Measured on an AC OPF recipe (6 variable blocks, 10 constraint blocks) compiled through ExaModelsC with an argument function, same library, same everything else: Vararg{Any,N} alone 10 verifier errors, map(f::Function, ...) Vararg{Any,N} + unrolling 0 errors, compiles in 110.1s So the two are complementary rather than alternatives. The recursion unrolls for a concrete tuple, giving each element a direct instantiate call instead of one through a closure. Semantics are unchanged, including that an element with no argument dependency is passed through untouched. #308's test models are small enough that its suite does not reach this; any recipe of OPF size instantiated at run time does.
compile_library(out, core, example...; argfun = f), or :name => (core, f, example) in the multi-model form — a function can never be a model argument, so second position is unambiguous. The library carries f and calls it at run time, so only f's own argument crosses the boundary: one string or one integer, with the work that turns it into instantiation data happening inside. It composes with the builder rather than competing — the builder passes structured data across, this passes a path and keeps the data on the far side. UserArgs is a ModelSpec.field variant emitted through _instantiation_source like the others, so surfaces coexist per model in one library. _argkind is emitted for EVERY model with the builder in the enum: 0 fixed, 1 _new(n), 2 _new_str, 3 builder. f is emitted BY NAME (Pkg.fun) — what juliac resolves statically — and its package must therefore be IMPORTED, not merely pinned by _developed_packages: pinning leaves ExaModelsPower.opf_args a global of unknown type and trimming refuses the call (measured: 18 verifier errors, all that one statement). The example is f's RETURN tuple and so splats like a builder's. Verified on this tip: one library, four models — AC polar, AC rect and DC each with an argument function, plus one fixed core — compiles in 110.6s with 0 verifier errors, and every model matches its in-Julia counterpart on dims, obj, grad, cons, jac and hess at case14 and at case118, which none was compiled against. The argument-function and fixed surfaces give bit-identical answers for the same model.
…urface
The docstring documents the third surface and the P_argkind enum; a
three-argument _model_spec keeps the no-argfun spelling every existing
caller uses. The fixture package gains one argument function of each
kind, and the four-surface library — builder, one-knob, argfun:int,
argfun:str, one file — is compiled and exercised in the suite: argkind
asserted for every model, the integer kind driven through P_new via the
consumer, the string kind through P_new_str with P_new refusing.
Two prior assertions matched runtime names as strings and broke on
_argkind's comment merely NAMING them; they now match definitions
("function bs_new("), not mentions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_argfun_call accepted any function whose module is its own moduleroot — and Main satisfies that, so a closure or function defined in a script passed the guard and failed as unresolved getglobal(Main, ...) calls after minutes of juliac. Requiring the module to be package-owned turns that into a first-second ArgumentError naming the actual constraint: the generated app calls the function by name from another process, so only a package-owned function can be reached.
Named at Main's top level, such a function is its own moduleroot and used to pass the guard, dying minutes later inside juliac; only the package-ownership check refuses it. Defined at the test file's top level — the world must have advanced before the probe call that precedes the guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed one commit: The problem. The change. The body is stored as a node, built once against a Evidence. Full suite on this branch: matches the pre-change baseline Corroboration for 97fc878 from a second package: on COPSBenchmark the 🤖 Generated with Claude Code The second commit is a small simplification to |
`@add_expr` stored its body as the generator's closure and re-invoked it at each point of use. Inside a model-building function that closure captures the `Variable`s the expression mentions, so when a size is a placeholder the `ArgSource` ends up in the closure's *type* — where `instantiate` cannot reach it, and where it cannot be rebuilt field-wise either, since Julia emits no constructor for a closure type. ExaModels then refused the core. (At top level the same model appears to work: a global is captured by reference and nothing lands in the closure's type. Every real builder is a function, so that difference is a trap rather than a reprieve.) The body is now a node, built once against a `DataSource`, and indexing an `Expression` substitutes into it. Nodes have constructors and dispatch, and the operation lives in the type parameter, so `_reindex` is static: no reflection, no `@generated`. Once the source is replaced by a concrete element — an integer, or the tuple a multi-dimensional generator destructures — the lookups above it are performed rather than rebuilt. Suite matches main exactly: 1617 passed, 0 errored, 2 broken, 1619 total, against a measured main baseline of the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_owning_package` distinguished a package from an extension so it could take the name and uuid from the module's own `PkgId` in one case and read the `Project.toml` in the other. The distinction earns nothing: an extension's `pkgdir` is already its parent package's directory, so reading the project there gives the right name and uuid either way. One code path, and one fewer Base internal (`locate_package`). The extension case is still covered by the fixture's `RecipeKernelsExaModels` leg; the testset is unchanged at 12 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review notes from the consumer seat: kind 1 covers both n-is-the-size and n-goes-to-an-argument-function, identical call shape on purpose — say so, so no consumer goes probing for a difference. And _nargs (how many) with _argkind (what shape) are the two halves a consumer handed only a library path needs; tie them together where they are documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An extension passes every predicate the guard had — an extension is its own moduleroot, and `_owning_package` deliberately maps it to its parent — but the generated app imports only packages, so a call spelled `SomePkgExt.f` names a module the app never has, and the failure surfaced as an UndefVarError inside generated code, minutes into juliac. The owning package's name matching the module's own is what separates "defined by a package" from "defined by its extension"; the error now says to move the function to the parent package, which is the same relocation the start generators needed for serialization. The fixture's extension gains a tuple-returning function so the test reaches the guard rather than the tuple-return check ahead of it; the Main-guard test's expected substring tightens to the part of the message both cases share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_adapt_gen's fallback wrapped a placeholder iterable in a deferred
collect. At instantiation that runs collect on whatever the argument
resolved to — and collect of a device array is a host Vector, so a
converted argument was silently pulled back to the CPU and the kernel met
a non-bitstype:
Argument 5 ... Vector{ExaPowerIO.GenData{Float32}}, which is not a bitstype
Invisible on the CPU, where collect of a Vector is just a copy — which is
why every CPU gate stayed green while the CUDA leg failed at the first
recipe-built model.
The deferred collect now goes through _maybe_collect, the identity on
AbstractArray and AbstractRange and collect otherwise, so an exotic
iterable (a dict, a generator) still materializes while an array of either
residence passes through as itself.
Measured: an AC OPF recipe instantiated with CUDA-converted arguments
built a model whose objective iterable was a host Vector before, a
CuArray after; rect Float32 and polar Float64 both build and evaluate on
the GPU after the fix.
A standalone scalar term in an @add_expr body (i - 1) plus a CONCRETE index into the expression hands the stored node two Real children — and register.jl's one-sided Real evaluation methods tie on that shape, so the docs' distillation example died ambiguous at the adjoint call. The closure the node storage replaced computed such terms eagerly; _reindex now folds them (F.instance exists because ops are named functions), so a node with only Real children is never constructed. Regression test at the subexpr suite, green on nothing/CPU/CUDA; ArgumentTest 160/160 with CUDA; ExaModelsC 210/210. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…needs Two faults, both mine and both caught by CI doing its job. The NLPModels entry in [extras] carried a fabricated UUID — the first group was right and the tail was written from memory, so every test leg failed at resolution with 'expected package to be registered'. Corrected to the registered UUID and verified against the registry. The registered ExaModels 0.11.2 predates the recipe API: the repo carries that version number but was last registered before ExaCore(nargs = ...) existed, so no leg can resolve a usable ExaModels from the registry, and the CUDA leg additionally needs the deferred-collect residence fix that is part of madsuite-org/ExaModels.jl#308. Pinned by [sources] to the #308 tip SHA in both the package and docs projects — verified to resolve and carry the API. The pin is temporary scaffolding: it comes out when ExaModels next registers, and this package cannot itself be registered until then, which the PR notes.
…rted
The unroll's two parameters have load-bearing roles, both of them: the
ELEMENTS must stay in a structural Tuple (recursed via first/Base.tail)
and the ARGUMENTS must ride the specialized Vararg{Any,N}. The previous
shape splatted the elements into a bare pass-through vararg with the
arguments as a plain tuple — Julia leaves a pass-through vararg
unspecialized, so heterogeneous element types are lost, and a core
whose objectives project fields out of a deferred data call widens at
the instantiate boundary: 6 verifier errors on real COPS gasoil,
invisible to the ExaModelsC suite (models too small and homogeneous)
and to four increasingly faithful reconstructions of the shape.
Bisected to this hunk alone: 6 -> 0 on the swap with gasoil as the
instrument. The COPS CI pin on this branch is the standing regression
gate for the class; the implementation is the reviewer's, adopted
byte-for-byte from the tree that was green all along.
Gates: real gasoil compiles (0 verifier errors); ExaModelsC 210/210;
ArgumentTest 160/160 with CUDA on hardware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
compile_librarynow takes any number of:name => core/:name => (core, args...)pairs and compiles them into one shared library, each model under its own symbol prefix — and, per model, compiles any argument shapeExaModeltakes, emitting the matching instantiation surface:Multi-model: the generated module suffixes all per-model state (
CORE_$p,MODELS_$p, ...), so models keep separate cores, instance tables, and ids; the library FILE is named afterout, the model names supply the prefixes.Structured instantiation: recipes whose examples are not a single integer emit the ABI v2 schema + builder surface both consumers already implement —
P_schemapublishes the flattened field list, typed setters take values by name,P_new_from_datareassembles theExaModelarguments. Builder storage is generated concretely from the example types (Int64/Float64exactly; anything looser is refused with the reason).Argument functions:
argfun = f(or aFunctionin the pair's second position — nothing callable can be a model argument, so the spelling is unambiguous) carriesfinto the library and calls it at run time: onlyf's own argument crosses the boundary — one string (P_new_str) or one integer (P_new) — and the work that turns it into instantiation data happens inside.fmust be a named function owned by a package proper: functions from scripts/REPL (unreachable from the generated app) and from package extensions (their module is not a name the app imports) are refused in the first second with the constraint named, rather than dying minutes into juliac.Every model exports
P_argkind() -> 0|1|2|3(fixed,P_new(n),P_new_str, builder), so a consumer routes on declared shape instead of probing symbols; kind 1 covers n-is-the-size and n-goes-to-the-function indistinguishably by design.P_nargs(how many) andP_argkind(what shape) are the two halves a consumer handed only a library path needs.Subexpressions work in recipes:
@add_exprnow stores its body as a node (substituted via a static_reindexwalk) rather than a closure — a closure captures theVariables it mentions, putting a placeholder'sArgSourceinto the closure's type, whereinstantiatecannot reach it. Verified against a measured main baseline (1617/0/2, identical), plus empirical probes ofsum(...)inside bodies, including index-dependent summands (exact).Developed packages are pinned: every package the caller tracks by path becomes a
[deps]+[sources]entry of the generated app (not an import — resolution needs the pin; only the cores' own types need imports), so the app compiles the code the caller runs rather than the registry copy. Also in ExaModels proper:instantiate's varargs are specialization-forced (Vararg{Any,N}) and the container walk is unrolled — both are needed; large cores (AC OPF scale) leavemapclosures unresolved under--trim=safewith the annotation alone.Suite: 210/210 on Linux against current consumer masters — a three-model round-trip, the four-surface library (builder + one-knob + argfun:int + argfun:str in one file), Ipopt solves through both surfaces, refusal tests (strings, loose types, colliding names, script-, Main-, and extension-owned functions). Scale datum from independent review: an 18-model library assembles in 160 s with 0 verifier errors, exact readback at an unseen size. GPU datum from downstream validation: a real modelling library's CUDA test slice (ExaModelsPower#57, 322 tests) runs green against this branch's instantiation path — the leg that was dead before the
collectfix.Note for path-dev consumers: this adds
Pkgto ExaModelsC's dependencies — a stale manifest that path-devs ExaModelsC reportsPackage ExaModelsC does not have Pkg in its dependenciesuntilPkg.resolve(); that is the manifest, not the branch.Consumer-side name selection (
CNLPModel(lib, :acopf, ...)) is already on CNLPModels master; the_new_str/_argkindrouting follows as separate consumer work.🤖 Generated with Claude Code