Skip to content

Commit 5388bd9

Browse files
authored
ReactantServerExport: verify exported model arity so the user knows something is wrong with the export (#78)
1 parent 0e3d3e8 commit 5388bd9

4 files changed

Lines changed: 312 additions & 6 deletions

File tree

docs/src/bundles.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,48 @@ code exactly.
165165

166166
The test suite also builds small bundles directly; see `test/stablehlo_fixtures.jl`.
167167

168+
## Checking that a bundle is servable
169+
170+
A bundle is served as `executable(inputs..., weights...)`, so the compiled program must take exactly
171+
as many arguments as the manifest declares inputs plus the weights file holds tensors. Both of those
172+
halves are readable, and the number the executable actually wants is in neither: it lives inside
173+
`model*.mlir`, which is a serialized `vhlo` artifact rather than text. A bundle can therefore be
174+
internally inconsistent while every readable part of it looks correct, and the symptom is that the
175+
model registers and serves and then fails every inference with
176+
177+
```
178+
INVALID_ARGUMENT: Execution supplied 216 arguments but compiled program expected 217
179+
```
180+
181+
`export_bundle` now checks this itself and refuses to write a bundle whose graph disagrees with it,
182+
naming both numbers and the usual cause. The usual cause is a device-resident value reachable from
183+
the traced closure: Reactant lifts every one of those into an argument whether the program reads it
184+
or not, so an RNG in layer state (which appears as a leading `tensor<2xui64>`) or a device-resident
185+
configuration value captured by the model becomes an argument no client can supply. Reading such
186+
values back to the host before tracing bakes them into the graph as constants instead.
187+
188+
For bundles that were written before that check existed, `assert_bundle_arity` reads the same three
189+
numbers back out of the artifact:
190+
191+
```julia
192+
using ReactantServerExport
193+
194+
assert_bundle_arity("export_out/my_model_v1") # raises if the bundle is unservable
195+
r = bundle_arity_report("export_out/my_model_v1") # the numbers, without raising
196+
r.servable, r.n_inputs, r.n_weights, r.expected
197+
```
198+
199+
`bundle_arity_report` never raises, so it can be run across a directory of bundles to triage them,
200+
and `bundle_entry_arity` reads one module's arity on its own. All three read the artifact rather than
201+
the process that produced it, so they hold for a bundle from any writer.
202+
168203
## Related pages
169204

170205
The manifest and boundary types are documented on the [API](api.md) page: [`Manifest`](@ref),
171206
[`TensorSpec`](@ref), [`Dim`](@ref), [`BatchingSpec`](@ref), [`load_manifest`](@ref),
172-
[`DType`](@ref), and [`NamedTensor`](@ref). `export_bundle`, `write_bundle`, `IOSpec`, and
173-
`collect_provenance` are documented in the `ReactantServerExport` docstrings. The
207+
[`DType`](@ref), and [`NamedTensor`](@ref). `export_bundle`, `write_bundle`, `IOSpec`,
208+
`collect_provenance`, `assert_bundle_arity`, `bundle_arity_report`, and `bundle_entry_arity` are
209+
documented in the `ReactantServerExport` docstrings. The
174210
[Tutorial](tutorial.md) walks the full export-to-serve path, and
175211
[Node Configuration](node_config.md) covers how the server loads and watches a repository of
176212
bundles.

packages/ReactantServerExport/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "ReactantServerExport"
22
uuid = "90fa0446-f028-4638-8833-e223884d9b4d"
3-
version = "1.0.0"
3+
version = "1.1.0"
44
authors = ["Carroll Vance <cvance@medicalmetrics.com>"]
55

66
[deps]

packages/ReactantServerExport/src/ReactantServerExport.jl

Lines changed: 187 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const MLIR = Reactant.MLIR
2525
const Compiler = Reactant.Compiler
2626

2727
export IOSpec, write_bundle, export_bundle, collect_provenance
28+
export bundle_entry_arity, bundle_arity_report, assert_bundle_arity
2829

2930
# ============================================================================
3031
# Provenance
@@ -348,6 +349,186 @@ function write_bundle(
348349
return dir
349350
end
350351

352+
# ── The entry-arity gate ─────────────────────────────────────────────────────────────
353+
#
354+
# A bundle is served as `executable(inputs..., weights...)`. BOTH halves of that contract are
355+
# declared in artifacts a human can read: `manifest.yaml` lists the inputs and `weights.safetensors`
356+
# holds the weights. The number the EXECUTABLE actually wants is declared in neither. It lives inside
357+
# `model*.mlir`, which is a serialized vhlo artifact rather than text.
358+
#
359+
# So an export could emit a graph whose arity disagreed with its own bundle and report success. It
360+
# has happened twice, on two unrelated models, and both times the diagnosis started from a serving
361+
# error that names two numbers and no cause:
362+
#
363+
# 2026-08-11 INVALID_ARGUMENT: Execution supplied 216 arguments but compiled program expected 217
364+
# 2026-08-19 INVALID_ARGUMENT: Execution supplied 164 arguments but compiled program expected 167
365+
#
366+
# Both surpluses were values Reactant LIFTED out of the traced closure rather than baked: a
367+
# device-resident RNG seed in layer state, and device-resident configuration fields. Reactant lifts
368+
# every device-resident value reachable from the traced callable into an MLIR argument whether the
369+
# program reads it or not, and the `:lux` frontend below captures `st` and whatever `model` closes
370+
# over. Nothing here was comparing the result against the bundle it was about to write.
371+
#
372+
# The check is cheap and total: `compile_mlir` already returns the traced function's result, whose
373+
# `in_tys` ARE the emitted entry signature's input types (allocated from `linear_args`, which has
374+
# `skipped_args` removed before it is built), so the arity is in hand at no cost. Refusing here means
375+
# an unservable bundle is never written, rather than written, checked, registered, deployed and then
376+
# diagnosed from the far end.
377+
378+
# The compiled entry point's argument count, or `nothing` if this Reactant does not expose it. The
379+
# guard is deliberate: this reads a field of Reactant's internal trace result, and a gate that
380+
# silently stops gating is worse than one that admits it cannot look.
381+
function _entry_arity(fn_res)
382+
for f in (:in_tys, :linear_args)
383+
hasproperty(fn_res, f) && return length(getproperty(fn_res, f))
384+
end
385+
return nothing
386+
end
387+
388+
function _check_entry_arity(fn_res, n_inputs::Integer, n_weights::Integer, label::AbstractString)
389+
got = _entry_arity(fn_res)
390+
got === nothing && return nothing
391+
expected = Int(n_inputs) + Int(n_weights)
392+
got == expected && return nothing
393+
surplus = got - expected
394+
cause = if surplus > 0
395+
"A surplus argument is a value Reactant LIFTED out of the traced closure rather than baking \
396+
into the graph: it lifts every DEVICE-RESIDENT value reachable from the traced callable, \
397+
whether the program reads it or not. The known cases are an RNG in layer state \
398+
(`st.<layer>.rng.seed`, which appears as a leading `tensor<2xui64>`) and device-resident \
399+
configuration captured by the model. Read those back to the host before tracing and they \
400+
bake as constants instead of becoming arguments."
401+
else
402+
"A shortfall means the weights being serialized outnumber the graph's arguments, so the \
403+
parameter tree walked for serialization is not the one the graph was traced against."
404+
end
405+
return error(
406+
"""
407+
ReactantServerExport: the compiled program is UNSERVABLE and has not been written ($label).
408+
compiled entry arguments : $got
409+
declared inputs : $(Int(n_inputs))
410+
weights to serialize : $(Int(n_weights))
411+
expected : $expected ($(Int(n_inputs)) + $(Int(n_weights)))
412+
$(surplus > 0 ? "surplus" : "shortfall") of : $(abs(surplus))
413+
414+
A bundle is served as `executable(inputs..., weights...)`, so every argument must be a
415+
declared input or a serialized weight. $cause
416+
417+
Serving this would fail every inference with `Execution supplied $expected arguments but
418+
compiled program expected $got`, reported by a process that can give those two numbers and
419+
nothing about which value or which model is responsible."""
420+
)
421+
end
422+
423+
# ── Auditing a bundle that has already been written ──────────────────────────────────
424+
#
425+
# The gate above refuses an unservable bundle at the moment of writing, which is the right place for
426+
# every export from now on. It says nothing about the bundles already on disk, and after two
427+
# incidents "which of the existing artifacts are affected" is a question somebody has to be able to
428+
# answer without deploying each one and watching it fail.
429+
#
430+
# This is that answer, and it reads its three numbers from the artifact rather than from the process
431+
# that produced it, so it is also the check a bundle from ANY writer can be held to.
432+
433+
"""
434+
bundle_entry_arity(mlir_path) -> Int
435+
436+
The number of arguments the compiled program in `mlir_path` takes, read out of the StableHLO
437+
portable artifact.
438+
439+
`model*.mlir` is a serialized `vhlo` artifact, so `parse(MLIR.IR.Module, ...)` fails with
440+
"dialect 'vhlo' does not implement the bytecode interface": it has to be deserialized against a
441+
Reactant context first. That is the whole reason this is a function rather than a regex at a call
442+
site.
443+
"""
444+
function bundle_entry_arity(mlir_path::AbstractString)
445+
artifact = read(String(mlir_path), String)
446+
ctx = Reactant.ReactantContext()
447+
mref = Reactant.MLIR.API.stablehloDeserializePortableArtifactNoError(artifact, ctx)
448+
m = Reactant.MLIR.IR.Module(mref)
449+
txt = string(Reactant.MLIR.IR.Operation(m))
450+
i = findfirst("func.func", txt)
451+
i === nothing && error("ReactantServerExport: no `func.func` in the module at `$mlir_path`.")
452+
seg = txt[first(i):min(lastindex(txt), first(i) + 200_000)]
453+
k = findfirst(") -> ", seg)
454+
sig = k === nothing ? seg : seg[1:first(k)]
455+
return length(collect(eachmatch(r"%arg\d+\s*:", sig)))
456+
end
457+
458+
"""
459+
bundle_arity_report(dir) -> NamedTuple
460+
461+
Read a written bundle's three numbers and say whether they agree: the inputs its `manifest.yaml`
462+
declares, the tensors its `weights.safetensors` holds, and the arity of each compiled module.
463+
464+
Returns `(; name, n_inputs, n_weights, expected, modules, servable)`, where `modules` is one
465+
`(; module_file, entry_args, servable)` per `*.mlir`. Nothing is thrown for a mismatch, so this can
466+
be run across a directory of bundles to triage them; [`assert_bundle_arity`](@ref) is the same check
467+
as a refusal.
468+
"""
469+
function bundle_arity_report(dir::AbstractString)
470+
d = String(dir)
471+
manifest = YAML.load_file(joinpath(d, "manifest.yaml"))
472+
ni = length(get(manifest, "executable_inputs", []))
473+
nw = _safetensors_tensor_count(joinpath(d, "weights.safetensors"))
474+
expected = ni + nw
475+
mlirs = sort(filter(f -> endswith(f, ".mlir"), readdir(d; join = true)))
476+
isempty(mlirs) && error("ReactantServerExport: no `*.mlir` under `$d`.")
477+
mods = map(mlirs) do p
478+
got = bundle_entry_arity(p)
479+
return (; module_file = basename(p), entry_args = got, servable = got == expected)
480+
end
481+
return (;
482+
name = get(manifest, "name", basename(normpath(d))),
483+
n_inputs = ni, n_weights = nw, expected,
484+
modules = mods, servable = all(m -> m.servable, mods),
485+
)
486+
end
487+
488+
"""
489+
assert_bundle_arity(dir) -> NamedTuple
490+
491+
[`bundle_arity_report`](@ref) as a refusal: raise unless every compiled module in `dir` takes exactly
492+
`declared inputs + serialized weights` arguments. Returns the report when it passes.
493+
"""
494+
function assert_bundle_arity(dir::AbstractString)
495+
r = bundle_arity_report(dir)
496+
r.servable && return r
497+
rows = join(
498+
[
499+
" $(m.module_file): $(m.entry_args)$(m.servable ? "" : " <- disagrees")"
500+
for m in r.modules
501+
], "\n"
502+
)
503+
return error(
504+
"""
505+
ReactantServerExport: bundle `$(r.name)` is UNSERVABLE. Its compiled program's arity does not
506+
match the bundle around it, and both readable halves of a bundle can be correct while that is
507+
true, which is why this is read from the graph.
508+
declared inputs : $(r.n_inputs)
509+
tensors in weights.safetensors : $(r.n_weights)
510+
expected entry arguments : $(r.expected)
511+
compiled modules:
512+
$rows
513+
514+
A surplus argument is usually a value Reactant lifted out of the traced closure rather than
515+
baking: a device-resident RNG seed in layer state appears as a leading `tensor<2xui64>`, and
516+
device-resident configuration captured by the model appears in its own shape. Serving this
517+
fails every inference with an argument-count mismatch."""
518+
)
519+
end
520+
521+
# safetensors: little-endian UInt64 header length, then that many bytes of JSON. `ltoh` is a no-op on
522+
# x86 and the difference between correct and accidentally correct anywhere else.
523+
function _safetensors_tensor_count(path::AbstractString)
524+
isfile(path) || error("ReactantServerExport: no `weights.safetensors` at `$path`.")
525+
return open(path, "r") do io
526+
n = ltoh(read(io, UInt64))
527+
hdr = JSON3.read(String(read(io, Int(n))))
528+
return count(k -> String(k) != "__metadata__", keys(hdr))
529+
end
530+
end
531+
351532
# ============================================================================
352533
# Reactant tracing frontend (the former LuxExport; needs Reactant, not Lux)
353534
# ============================================================================
@@ -446,7 +627,8 @@ function export_bundle(
446627
ctx = Reactant.ReactantContext()
447628
push!(ctxs, ctx)
448629
args = (Reactant.to_rarray(x), map(Reactant.to_rarray, warrays)...)
449-
mod, _ = Compiler.compile_mlir(ctx, g, args; drop_unsupported_attributes = true)
630+
mod, fn_res = Compiler.compile_mlir(ctx, g, args; drop_unsupported_attributes = true)
631+
_check_entry_arity(fn_res, 1, length(warrays), "batch size $s")
450632
modules[Int(s)] = mod
451633
in_shape_julia = collect(Int, size(x))
452634
end
@@ -555,7 +737,8 @@ function export_bundle(
555737
ctx = Reactant.ReactantContext()
556738
push!(ctxs, ctx)
557739
args = (_modelarg(map(Reactant.to_rarray, xs)), map(Reactant.to_rarray, warrays)...)
558-
mod, _ = Compiler.compile_mlir(ctx, g, args; drop_unsupported_attributes = true)
740+
mod, fn_res = Compiler.compile_mlir(ctx, g, args; drop_unsupported_attributes = true)
741+
_check_entry_arity(fn_res, nin, length(warrays), "batch size $s")
559742
modules[Int(s)] = mod
560743
for i in 1:nin
561744
in_shapes[i] = collect(Int, size(xs[i]))
@@ -609,7 +792,8 @@ function export_bundle(
609792

610793
ctx = Reactant.ReactantContext()
611794
args = (map(Reactant.to_rarray, inputs)..., map(Reactant.to_rarray, warrays)...)
612-
mod, _ = Compiler.compile_mlir(ctx, f, args; drop_unsupported_attributes = true)
795+
mod, fn_res = Compiler.compile_mlir(ctx, f, args; drop_unsupported_attributes = true)
796+
_check_entry_arity(fn_res, length(inputs), length(warrays), "unbatched")
613797

614798
in_specs = [
615799
IOSpec(innames[i], eltype(inputs[i]), collect(Int, size(inputs[i])))

packages/ReactantServerExport/test/runtests.jl

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ end
6868
using ReactantServerExport
6969
using ReactantServer
7070
using Lux
71+
# `Reactant` for building a device-resident value in the entry-arity test below. Imported HERE and
72+
# not at the top: the PythonCall block above must initialize torch's native libraries before
73+
# Reactant's MLIR/LLVM, and this line is past it.
74+
using Reactant
7175

7276
# Load a bundle and run it through the ReactantServer runtime (CPU backend).
7377
function run_bundle(root, name, inputs::Vector{<:Pair})
@@ -184,6 +188,88 @@ _load_manifest(dir) = ReactantServer.parse_manifest(
184188
end
185189
end
186190

191+
192+
@testset "entry-arity gate refuses an unservable bundle before writing it" begin
193+
# THE 2026-08-11 AND 2026-08-19 DEFECT, reproduced. `export_bundle(:lux, ...)` CAPTURES `st`
194+
# in the closure it compiles rather than passing it, and Reactant lifts every device-resident
195+
# value reachable from a traced callable into an MLIR argument whether the program reads it or
196+
# not. The `st` below stands in for the real case, which is `st.<layer>.rng.seed` on any Lux
197+
# model carrying a `Dropout`: two `UInt64`s, never read in eval mode, and the difference
198+
# between a servable bundle and one that fails every inference.
199+
lifting_model(x, ps, st) = (ps.W * x, st)
200+
W = Float32[1 0 0; 0 1 0]
201+
st_dev = (; seed = Reactant.to_rarray(UInt64[1, 2]))
202+
203+
mktempdir() do root
204+
dir = joinpath(root, "lifted")
205+
err = try
206+
export_bundle(
207+
:lux, lifting_model, (; W = W), st_dev, randn(Float32, 3, 1);
208+
dir = dir, name = "lifted"
209+
)
210+
nothing
211+
catch e
212+
e
213+
end
214+
@test err isa ErrorException
215+
@test occursin("UNSERVABLE", err.msg)
216+
# x + W + the lifted seed = 3, against one declared input plus one weight.
217+
@test occursin("compiled entry arguments : 3", err.msg)
218+
@test occursin("expected : 2", err.msg)
219+
@test occursin("rng", err.msg) # the message names the known cause
220+
# Refusing BEFORE the write is the point: the old failure wrote a complete-looking bundle
221+
# and was diagnosed from a server that could report two numbers and no cause.
222+
@test !isfile(joinpath(dir, "weights.safetensors"))
223+
@test !isfile(joinpath(dir, "manifest.yaml"))
224+
end
225+
end
226+
227+
@testset "auditing a bundle that is already on disk" begin
228+
rng = Random.Xoshiro(0)
229+
model = Lux.Chain(Lux.Dense(4 => 8, tanh), Lux.Dense(8 => 3))
230+
ps, st = Lux.setup(rng, model)
231+
232+
mktempdir() do root
233+
dir = joinpath(root, "audited")
234+
export_bundle(
235+
:lux, model, ps, st, randn(Float32, 4, 1);
236+
dir = dir, name = "audited", batch_sizes = [1, 4]
237+
)
238+
239+
r = bundle_arity_report(dir)
240+
@test r.servable
241+
@test r.name == "audited"
242+
@test r.n_inputs == 1
243+
@test r.n_weights == 4 # two Dense layers, weight and bias each
244+
@test r.expected == r.n_inputs + r.n_weights
245+
@test length(r.modules) == 2 # one per compiled batch size
246+
@test all(m -> m.entry_args == r.expected, r.modules)
247+
@test bundle_entry_arity(joinpath(dir, "model.b1.mlir")) == r.expected
248+
@test assert_bundle_arity(dir).servable
249+
250+
# A bundle whose graph and manifest disagree is exactly what neither readable half of a
251+
# bundle shows, so give the manifest a second declared input and check the audit notices.
252+
# This is the shape of the incident, reached from the artifact rather than from a trace.
253+
man = joinpath(dir, "manifest.yaml")
254+
m = ReactantServer.YAML.load_file(man)
255+
push!(m["executable_inputs"], deepcopy(m["executable_inputs"][1]))
256+
ReactantServer.YAML.write_file(man, m)
257+
258+
bad = bundle_arity_report(dir)
259+
@test !bad.servable
260+
@test bad.expected == r.expected + 1
261+
e = try
262+
assert_bundle_arity(dir)
263+
nothing
264+
catch err
265+
err
266+
end
267+
@test e isa ErrorException
268+
@test occursin("UNSERVABLE", e.msg)
269+
@test occursin("audited", e.msg)
270+
end
271+
end
272+
187273
if HAS_TORCH
188274
np = pyimport("numpy")
189275
torch = pyimport("torch")

0 commit comments

Comments
 (0)