Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,12 @@ jobs:
benchmark/comparison.txt
benchmark/pr_number.txt

# ExaModelsC compiles a model into a native shared library with juliac, so it
# ExaModelsCompiler compiles a model into a native shared library with juliac, so it
# is platform-specific in a way the rest of the package is not. Not on GPU
# runners, where it is not expected to work yet, and not on Windows: juliac
# implements runtime privatization only for Linux and macOS, so a library
# compiled there cannot be loaded back into Julia (see `compile_library`).
examodelsc:
examodelscompiler:
strategy:
fail-fast: false
matrix:
Expand All @@ -355,7 +355,7 @@ jobs:
name: linux
- runner: '["self-hosted","macOS"]'
name: macos
name: examodelsc (${{ matrix.name }})
name: examodelscompiler (${{ matrix.name }})
runs-on: ${{ fromJSON(matrix.runner) }}
steps:
- uses: actions/checkout@v4
Expand All @@ -377,20 +377,20 @@ jobs:
# on a self-hosted Mac does not exist (`mkdir: Permission denied`), and
# takes the job with it. The runners have their own Python; the test
# probes for one with numpy and skips that leg when it finds none.
- name: Run ExaModelsC tests
- name: Run ExaModelsCompiler tests
env:
CNLPMODELS_PY: ${{ github.workspace }}/cnlpmodels-py/src
# The linux leg is instrumented so ExaModelsC/src reaches the coverage
# The linux leg is instrumented so ExaModelsCompiler/src reaches the coverage
# report; the mac leg runs the same tests uninstrumented.
run: |
julia --startup-file=no --color=yes ${{ matrix.name == 'linux' && '--code-coverage=user' || '' }} --project=ExaModelsC/test -e '
julia --startup-file=no --color=yes ${{ matrix.name == 'linux' && '--code-coverage=user' || '' }} --project=ExaModelsCompiler/test -e '
using Pkg
Pkg.instantiate()
include("ExaModelsC/test/runtests.jl")'
include("ExaModelsCompiler/test/runtests.jl")'
- uses: julia-actions/julia-processcoverage@v1
if: matrix.name == 'linux'
with:
directories: ExaModelsC/src
directories: ExaModelsCompiler/src
- uses: codecov/codecov-action@v5
if: matrix.name == 'linux'
with:
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion ExaModelsC/Project.toml → ExaModelsCompiler/Project.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name = "ExaModelsC"
name = "ExaModelsCompiler"
uuid = "3d1e9a26-5b74-4f0c-9a2b-7c8f4e11d3a7"
version = "0.1.0"
authors = ["Sungho Shin <sushin@mit.edu>"]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
ExaModelsC
ExaModelsCompiler

Compile an [`ExaModels.ExaCore`](@ref) into a shared library that
exposes the model through the plain C interface consumed by
Expand All @@ -11,7 +11,7 @@ The core is the compile-time artifact. It is built once against
open — and the compiled library resolves them per instance:

```julia
using ExaModels, ExaModelsC
using ExaModels, ExaModelsCompiler

c, N = ExaCore(nargs = Val(1))
@add_var(c, x, N; start = 1.0)
Expand Down Expand Up @@ -72,7 +72,7 @@ They share the library file and, in a bundle, its one privatized ~80 MB Julia
runtime — which is the reason to co-package a family of models rather than emit
a library each.
"""
module ExaModelsC
module ExaModelsCompiler

import ExaModels
import JuliaC
Expand Down Expand Up @@ -570,14 +570,14 @@ function _schema_json(fields::Vector{Field})
"]}" :
"""{"name":"$(f.name)","kind":"$(f.kind)","type":"$(f.type)"}"""
end
return """{"abi":2,"fields":[""" * join(parts, ",") * "]}"
return """{"fields":[""" * join(parts, ",") * "]}"
end

# `P_new(n)` stays as the fast path: one integer placeholder needs no builder.
_is_scalar_new(fields) =
length(fields) == 1 && fields[1].kind == "scalar" && fields[1].type == "i64"

# Everything else gets the schema + builder surface (ABI v2): the consumer
# Everything else gets the schema + builder surface: the consumer
# opens a builder, sets each field by name, and `P_new_from_data` reassembles
# the `ExaModel` arguments exactly as the example values were given here.
# `argspec` records that mapping — one entry per `ExaModel` positional
Expand Down Expand Up @@ -809,7 +809,7 @@ end
# ── Generating the app package ────────────────────────────────────────────────

function _generate_app(specs::Vector{ModelSpec}, libname::AbstractString)
appdir = mktempdir(; prefix = "examodelsc_")
appdir = mktempdir(; prefix = "examodelscompiler_")
modname = _modname(libname)
srcdir = joinpath(appdir, "src")
mkpath(srcdir)
Expand Down Expand Up @@ -997,7 +997,7 @@ _nargs_value(::Union{Nothing, Symbol}) = 1
# ── Generating one model's instantiation surface ─────────────────────────────
#
# A fixed or one-integer model gets `P_nargs` + `P_new(n)`. Everything else
# gets the schema + builder surface (ABI v2) and NO `P_new` — the consumers
# gets the schema + builder surface and NO `P_new` — the consumers
# rely on that disjointness to route a lone integer. All storage is
# concretely typed from the example values, so `--trim=safe` sees no dynamic
# containers.
Expand Down Expand Up @@ -1159,7 +1159,7 @@ function _instantiation_source(p::AbstractString, bm::BuilderModel)

return """
$(_argkind_source(p, bm))
# ── builder for `$p` (schema + typed setters, ABI v2) ────────────────────
# ── builder for `$p` (schema + typed setters) ────────────────────────────

const SCHEMA_$p = Vector{UInt8}($(repr(json)))

Expand Down Expand Up @@ -1319,6 +1319,152 @@ end
# coexist in the one module: separate cores, separate instance tables,
# separate ids. The entry points are `Base.@ccallable`, and juliac's
# `add_ccallables` picks up all of them regardless of how many there are.
# ── Publishing a model's named blocks ────────────────────────────────────────
#
# `@add_var(c, x, ...)` registers `x` in the core's `refs`, and a consumer that
# only has a compiled library has no other way to know which slice of the
# solution `x` occupies. These accessors publish that: for each named
# variable, constraint and parameter block, its kind, offset, length and dims.
#
# The layout is INSTANCE state, not library state — a recipe instantiated at
# another size reports that size's offsets — which is why every accessor takes
# an id. Names are compile-time literals; only they need the copy-out
# convention, so the numbers stay numeric and nothing formats a string inside
# the compiled library.
_block_kind(::ExaModels.AbstractVariable) = 0
_block_kind(::ExaModels.AbstractConstraint) = 1
_block_kind(::ExaModels.AbstractParameter) = 2
_block_kind(_) = -1

# Named blocks worth publishing, in `refs` order: variables, constraints and
# parameters. Objectives and subexpressions are named too, but they index no
# solution vector, so a consumer has nothing to address them with.
_layout_blocks(core) =
[(k, _block_kind(getfield(core.refs, k))) for k in keys(core.refs) if
_block_kind(getfield(core.refs, k)) >= 0]

function _layout_source(p::AbstractString, core)
blocks = _layout_blocks(core)
n = length(blocks)

names = join((
""" const BNAME_$(p)_$(i) = Vector{UInt8}($(repr(String(k))))\n"""
for (i, (k, _)) in enumerate(blocks)
))

name_arms = join((
"""
if k == Cint($(i - 1))
b = BNAME_$(p)_$(i)
n = length(b)
m = min(Int(cap), n)
if m > 0 && buf != Ptr{UInt8}(0)
GC.@preserve b unsafe_copyto!(buf, pointer(b), m)
end
return Cint(n)
end
"""
for (i, (k, _)) in enumerate(blocks)
))

block_arms = join((
"""
if k == Cint($(i - 1))
b = m.refs.$(k)
dims = ExaModels.size(getfield(b, :size))
unsafe_store!(out, Cint($(kind)), 1)
unsafe_store!(out, Cint(getfield(b, :offset)), 2)
unsafe_store!(out, Cint(ExaModels.total(getfield(b, :size))), 3)
unsafe_store!(out, Cint(length(dims)), 4)
for j in 1:length(dims)
unsafe_store!(out, Cint(dims[j]), 4 + j)
end
return Cint(0)
end
"""
for (i, (k, kind)) in enumerate(blocks)
))

par_arms = join((
"""
if k == Cint($(i - 1))
b = m.refs.$(k)
o = getfield(b, :offset)
n = ExaModels.total(getfield(b, :size))
Int(len) == n || return Cint(3)
return _value_$(p)_rw(m, o, n, vals, get)
end
"""
for (i, (k, kind)) in enumerate(blocks) if kind == 2
))

return """
# ── named blocks of `$p` ─────────────────────────────────────────────────
$names
# How many named blocks this model publishes.
Base.@ccallable function $(p)_nblocks(id::Cint)::Cint
_valid_$p(id) || return Cint(-1)
return Cint($n)
end

# The block's name, by the copy-out convention `$(p)_schema` uses:
# returns the needed byte length, copies what fits in `cap`.
Base.@ccallable function $(p)_block_name(id::Cint, k::Cint, buf::Ptr{UInt8}, cap::Cint)::Cint
_valid_$p(id) || return Cint(-1)
$name_arms return Cint(-1)
end

# `out` receives [kind, offset, length, ndims, dims...] — kind 0 = variable,
# 1 = constraint, 2 = parameter; offset is 0-based, as `solution` expects.
# A caller sizes `out` from `ndims` after a first call, or allocates
# generously: no model has more dimensions than it has blocks.
Base.@ccallable function $(p)_block(id::Cint, k::Cint, out::Ptr{Cint})::Cint
_valid_$p(id) || return Cint(1)
try
m = MODELS_$p[Int(id)]
$block_arms return Cint(1)
catch
return Cint(2)
end
end

# Parameters are live model state: `θ` is read and written in place, so a
# consumer can re-solve at new parameter values without rebuilding.
@inline function _value_$(p)_rw(m, o::Int, n::Int, vals::Ptr{Cdouble}, get::Bool)
w = unsafe_wrap(Array, vals, n)
if get
copyto!(w, view(m.θ, (o + 1):(o + n)))
else
copyto!(view(m.θ, (o + 1):(o + n)), w)
end
return Cint(0)
end

@inline function _value_$(p)_at(id::Cint, k::Cint, vals::Ptr{Cdouble}, len::Cint, get::Bool)
_valid_$p(id) || return Cint(1)
try
m = MODELS_$p[Int(id)]
$par_arms return Cint(1)
catch
return Cint(2)
end
end

# `get_value` / `set_value`, matching ExaModels' two-stage parameter
# vocabulary. Status 0 on success, 1 for a bad id or a block that is not
# a parameter, 3 for a length that disagrees with the block. Unlike
# ExaModels' `get_value`, this copies out rather than returning a view:
# the storage lives in the library's address space, not the caller's.
Base.@ccallable function $(p)_get_value(id::Cint, k::Cint, vals::Ptr{Cdouble}, len::Cint)::Cint
return _value_$(p)_at(id, k, vals, len, true)
end

Base.@ccallable function $(p)_set_value(id::Cint, k::Cint, vals::Ptr{Cdouble}, len::Cint)::Cint
return _value_$(p)_at(id, k, vals, len, false)
end
"""
end

function _model_source(s::ModelSpec)
p = s.prefix
# A builder spec's example is the whole tuple of values, splatted back the
Expand All @@ -1343,6 +1489,7 @@ function _model_source(s::ModelSpec)
@inline _valid_$p(id::Cint) = 1 <= id <= length(MODELS_$p)

$(_instantiation_source(p, s.field))
$(_layout_source(p, s.core))
Base.@ccallable function $(p)_nvar(id::Cint)::Cint
_valid_$p(id) || return Cint(-1)
return Cint(MODELS_$p[Int(id)].meta.nvar)
Expand Down Expand Up @@ -1594,4 +1741,4 @@ function _link(::Val{false}, img, libname, outdir)
return (; libpath, outdir, libname)
end

end # module ExaModelsC
end # module ExaModelsCompiler
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[deps]
CNLPModels = "e8ddee8d-ce21-405b-9ec9-45cea2c1b284"
ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
ExaModelsC = "3d1e9a26-5b74-4f0c-9a2b-7c8f4e11d3a7"
ExaModelsCompiler = "3d1e9a26-5b74-4f0c-9a2b-7c8f4e11d3a7"
NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
Expand All @@ -10,7 +10,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[sources]
ExaModels = {path = "../.."}
ExaModelsC = {path = ".."}
ExaModelsCompiler = {path = ".."}
# A stand-in modelling library, so a recipe can be built that owns a type no
# other package does — see the "a recipe's own package" testset.
RecipeKernels = {path = "fixtures/RecipeKernels"}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Evaluate a compiled ExaModelsC library through the Python consumer.
"""Evaluate a compiled ExaModelsCompiler library through the Python consumer.

Loads the shared library with `cnlpmodels` (https://github.com/MadNLP/cnlpmodels-py)
and writes its readings to a text file for the Julia side to compare against
Expand Down
Loading
Loading