Skip to content

Commit 99b296a

Browse files
asinghvi17claude
andcommitted
test: add trim (static compilation) test infrastructure
Add test/trim/ sub-environment following the existing pattern (like test/ad/, test/downstream/): - trim_tests.jl generates per-solver test projects (main.jl + Project.toml) from a SOLVER_CONFIGS dict, compiles each with JuliaC --trim=safe, and validates the output. - Wire into runtests.jl as GROUP="Trim" (requires Julia 1.12+). - Tests: tsit5, fbdf, rodas5p, auto (DefaultODEAlgorithm). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2045e9c commit 99b296a

4 files changed

Lines changed: 289 additions & 0 deletions

File tree

test/runtests.jl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ function activate_enzyme_env()
2929
Pkg.instantiate()
3030
end
3131

32+
function activate_trim_env()
33+
Pkg.activate("trim")
34+
Pkg.instantiate()
35+
end
36+
3237
#Start Test Script
3338

3439
@time begin
@@ -197,4 +202,10 @@ end
197202
@time @safetestset "Reaction-Diffusion Stiff Solver GPU" include("gpu/reaction_diffusion_stiff.jl")
198203
@time @safetestset "Scalar indexing bug bypass" include("gpu/hermite_test.jl")
199204
end
205+
206+
# Trim (static compilation) tests — requires Julia 1.12+
207+
if !is_APPVEYOR && GROUP == "Trim" && VERSION >= v"1.12.0"
208+
activate_trim_env()
209+
@time @safetestset "Trim Tests" include("trim/trim_tests.jl")
210+
end
200211
end # @time

test/trim/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Generated at runtime by trim_tests.jl
2+
tsit5/
3+
fbdf/
4+
rodas5p/
5+
auto/
6+
logs/

test/trim/Project.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[deps]
2+
JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2"
3+
OrdinaryDiffEqBDF = "6ad6398a-0878-4a85-9266-38940aa047c8"
4+
OrdinaryDiffEqCore = "bbf590c4-e513-4bbe-9b18-05decba2e5d8"
5+
OrdinaryDiffEqDefault = "50262376-6c5a-4cf5-baba-aaf4f84d72d7"
6+
OrdinaryDiffEqRosenbrock = "43230ef6-c299-4910-a778-202eb28ce4ce"
7+
OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
8+
DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
9+
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
10+
11+
[sources]
12+
OrdinaryDiffEqCore = {path = "../../lib/OrdinaryDiffEqCore"}
13+
OrdinaryDiffEqTsit5 = {path = "../../lib/OrdinaryDiffEqTsit5"}
14+
OrdinaryDiffEqBDF = {path = "../../lib/OrdinaryDiffEqBDF"}
15+
OrdinaryDiffEqRosenbrock = {path = "../../lib/OrdinaryDiffEqRosenbrock"}
16+
OrdinaryDiffEqDefault = {path = "../../lib/OrdinaryDiffEqDefault"}
17+
DiffEqBase = {path = "../../lib/DiffEqBase"}

test/trim/trim_tests.jl

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# Trim (static-compilation) tests for OrdinaryDiffEq solver sub-packages.
2+
#
3+
# Each solver is compiled into a standalone native executable with
4+
# JuliaC --trim=safe, then run and validated against known output.
5+
#
6+
# To add a new solver test, add a SolverConfig entry to SOLVER_CONFIGS.
7+
8+
using Test
9+
using JuliaC
10+
11+
@assert VERSION >= v"1.12.0" "Trim tests require Julia 1.12+"
12+
13+
const TRIM_DIR = @__DIR__
14+
const LOG_DIR = joinpath(TRIM_DIR, "logs")
15+
16+
# ── Solver configuration ──────────────────────────────────────────────
17+
18+
Base.@kwdef struct SolverConfig
19+
pkg::String # OrdinaryDiffEq sub-package exporting the solver
20+
uuid::String # that package's UUID
21+
alg_type::String # the algorithm struct name to import
22+
constructor::String # Julia expression constructing the solver instance
23+
display_name::String # human-readable name for log messages
24+
end
25+
26+
const SOLVER_CONFIGS = Dict{String,SolverConfig}(
27+
"tsit5" => SolverConfig(
28+
pkg = "OrdinaryDiffEqTsit5",
29+
uuid = "b1df2697-797e-41e3-8120-5422d3b24e4a",
30+
alg_type = "Tsit5",
31+
constructor = "Tsit5()",
32+
display_name = "Tsit5",
33+
),
34+
"fbdf" => SolverConfig(
35+
pkg = "OrdinaryDiffEqBDF",
36+
uuid = "6ad6398a-0878-4a85-9266-38940aa047c8",
37+
alg_type = "FBDF",
38+
constructor = "FBDF(autodiff = AutoForwardDiff(chunksize = 1), linsolve = LUFactorization())",
39+
display_name = "FBDF",
40+
),
41+
"rodas5p" => SolverConfig(
42+
pkg = "OrdinaryDiffEqRosenbrock",
43+
uuid = "43230ef6-c299-4910-a778-202eb28ce4ce",
44+
alg_type = "Rodas5P",
45+
constructor = "Rodas5P(autodiff = AutoForwardDiff(chunksize = 1), linsolve = LUFactorization())",
46+
display_name = "Rodas5P",
47+
),
48+
"auto" => SolverConfig(
49+
pkg = "OrdinaryDiffEqDefault",
50+
uuid = "50262376-6c5a-4cf5-baba-aaf4f84d72d7",
51+
alg_type = "DefaultODEAlgorithm",
52+
constructor = "DefaultODEAlgorithm(autodiff = AutoForwardDiff(chunksize = 1), linsolve = LUFactorization())",
53+
display_name = "DefaultODEAlgorithm",
54+
),
55+
)
56+
57+
const SOLVER_ORDER = ["tsit5", "fbdf", "rodas5p", "auto"]
58+
59+
# Path from a generated sub-project back to the monorepo lib/ directory.
60+
# Generated projects live at test/trim/<solver>/, so ../../.. reaches the repo root.
61+
const LIB_REL_PATH = "../../.."
62+
63+
# All deps for generated sub-projects: name => (uuid, lib_subdir_or_nothing).
64+
# Packages with a non-nothing lib_subdir get a [sources] entry pointing to
65+
# the local monorepo checkout.
66+
const ALL_DEPS = Dict(
67+
"ADTypes" => ("47edcb42-4c32-4615-8424-f2b9edc5f35b", nothing),
68+
"LinearSolve" => ("7ed4a6bd-45f5-4d41-b270-4a48e9bafcae", nothing),
69+
"SciMLBase" => ("0bca4576-84f4-4d90-8ffe-ffa030f20462", nothing),
70+
"SciMLLogging" => ("a6db7da4-7206-11f0-1eab-35f2a5dbe1d1", nothing),
71+
"OrdinaryDiffEqCore" => ("bbf590c4-e513-4bbe-9b18-05decba2e5d8", "OrdinaryDiffEqCore"),
72+
"OrdinaryDiffEqTsit5" => ("b1df2697-797e-41e3-8120-5422d3b24e4a", "OrdinaryDiffEqTsit5"),
73+
"OrdinaryDiffEqBDF" => ("6ad6398a-0878-4a85-9266-38940aa047c8", "OrdinaryDiffEqBDF"),
74+
"OrdinaryDiffEqRosenbrock" => ("43230ef6-c299-4910-a778-202eb28ce4ce", "OrdinaryDiffEqRosenbrock"),
75+
"OrdinaryDiffEqDefault" => ("50262376-6c5a-4cf5-baba-aaf4f84d72d7", "OrdinaryDiffEqDefault"),
76+
"DiffEqBase" => ("2b5f629d-d688-5b77-993f-72d75c75574e", "DiffEqBase"),
77+
)
78+
79+
# ── Code generation ────────────────────────────────────────────────────
80+
81+
function generate_project_toml(cfg::SolverConfig)
82+
deps = Dict{String,String}()
83+
sources = Dict{String,String}()
84+
85+
# Add all shared deps + local packages
86+
for (pkg, (uuid, lib_subdir)) in ALL_DEPS
87+
deps[pkg] = uuid
88+
if lib_subdir !== nothing
89+
sources[pkg] = "$LIB_REL_PATH/lib/$lib_subdir"
90+
end
91+
end
92+
# The solver's own package (may already be in ALL_DEPS, that's fine — Dict deduplicates)
93+
deps[cfg.pkg] = cfg.uuid
94+
95+
deps_str = join(["$k = \"$v\"" for (k, v) in sort(collect(deps))], "\n")
96+
sources_str = join(["$k = {path = \"$v\"}" for (k, v) in sort(collect(sources))], "\n")
97+
98+
return """
99+
[deps]
100+
$deps_str
101+
102+
[sources]
103+
$sources_str
104+
"""
105+
end
106+
107+
function generate_main_jl(name::String, cfg::SolverConfig)
108+
return """
109+
using $(cfg.pkg): $(cfg.alg_type)
110+
using SciMLBase: ODEProblem, ODEFunction, solve
111+
using ADTypes: AutoForwardDiff
112+
using LinearSolve: LUFactorization
113+
using OrdinaryDiffEqCore: DEVerbosity
114+
using SciMLLogging: None
115+
116+
function lotka_volterra!(du, u, p, t)::Nothing
117+
α, β, γ, δ = p
118+
du[1] = α * u[1] - β * u[1] * u[2]
119+
du[2] = δ * u[1] * u[2] - γ * u[2]
120+
return nothing
121+
end
122+
123+
function run_solve(outfile::String, α::Float64, β::Float64)::Int
124+
p = [α, β, 2.0, 1.0]
125+
u0 = [1.0, 1.0]
126+
tspan = (0.0, 10.0)
127+
128+
f = ODEFunction{true}(lotka_volterra!)
129+
prob = ODEProblem(f, u0, tspan, p)
130+
solver = $(cfg.constructor)
131+
sol = solve(prob, solver; saveat = 0.1, abstol = 1e-8, reltol = 1e-8,
132+
verbose = DEVerbosity(None()))
133+
134+
io = open(outfile, "w")
135+
for i in eachindex(sol.t)
136+
print(io, sol.t[i], ",", sol.u[i][1], ",", sol.u[i][2], "\\n")
137+
end
138+
close(io)
139+
140+
return length(sol.t)
141+
end
142+
143+
function (@main)(args::Vector{String})::Int32
144+
if length(args) < 1
145+
Core.print(Core.stderr, "Usage: $(name) <outfile> [α] [β]\\n")
146+
return Int32(1)
147+
end
148+
outfile = args[1]
149+
α = length(args) >= 2 ? parse(Float64, args[2]) : 1.5
150+
β = length(args) >= 3 ? parse(Float64, args[3]) : 1.0
151+
152+
n = run_solve(outfile, α, β)
153+
Core.println("Solved Lotka-Volterra with $(cfg.display_name): \$(n) timesteps written to \$(outfile)")
154+
return Int32(0)
155+
end
156+
"""
157+
end
158+
159+
function generate_test_project(name::String, cfg::SolverConfig)
160+
test_dir = joinpath(TRIM_DIR, name)
161+
mkpath(test_dir)
162+
write(joinpath(test_dir, "Project.toml"), generate_project_toml(cfg))
163+
write(joinpath(test_dir, "main.jl"), generate_main_jl(name, cfg))
164+
# Remove stale Manifest so Pkg.instantiate picks up workspace changes
165+
manifest = joinpath(test_dir, "Manifest.toml")
166+
isfile(manifest) && rm(manifest)
167+
return test_dir
168+
end
169+
170+
# ── Compile & validate ─────────────────────────────────────────────────
171+
172+
function compile_solver(solver_name::String, build_dir::String)
173+
test_dir = joinpath(TRIM_DIR, solver_name)
174+
main_jl = joinpath(test_dir, "main.jl")
175+
176+
outname = joinpath(build_dir, solver_name)
177+
logfile = joinpath(LOG_DIR, "$(solver_name)_compile.log")
178+
179+
open(logfile, "w") do log
180+
redirect_stdio(; stdout = log, stderr = log) do
181+
img = JuliaC.ImageRecipe(;
182+
output_type = "--output-exe",
183+
trim_mode = "safe",
184+
file = abspath(main_jl),
185+
project = abspath(test_dir),
186+
verbose = true,
187+
)
188+
JuliaC.compile_products(img)
189+
190+
link = JuliaC.LinkRecipe(; image_recipe = img, outname = outname)
191+
JuliaC.link_products(link)
192+
193+
bundle = JuliaC.BundleRecipe(; link_recipe = link, output_dir = build_dir)
194+
JuliaC.bundle_products(bundle)
195+
end
196+
end
197+
198+
exe_path = Sys.iswindows() ? "$outname.exe" : outname
199+
if !isfile(exe_path)
200+
bundled_path = joinpath(build_dir, "bin", basename(outname))
201+
Sys.iswindows() && (bundled_path *= ".exe")
202+
isfile(bundled_path) && (exe_path = bundled_path)
203+
end
204+
205+
n_errors = count(contains("Verifier error"), readlines(logfile))
206+
return (exe_path, n_errors)
207+
end
208+
209+
function validate_output(exe_path::String, solver_name::String, build_dir::String)
210+
outfile = joinpath(build_dir, "$(solver_name)_output.csv")
211+
run(`$(exe_path) $(outfile) 1.5 1.0`)
212+
213+
lines = readlines(outfile)
214+
@test length(lines) > 10
215+
216+
parts = split(lines[1], ",")
217+
@test length(parts) == 3
218+
@test parse(Float64, parts[1]) == 0.0
219+
@test parse(Float64, parts[2]) > 0.0
220+
@test parse(Float64, parts[3]) > 0.0
221+
222+
last_parts = split(lines[end], ",")
223+
@test parse(Float64, last_parts[1]) 10.0
224+
225+
return length(lines)
226+
end
227+
228+
# ── Test entry point ───────────────────────────────────────────────────
229+
230+
function run_trim_tests(solvers = SOLVER_ORDER)
231+
mkpath(LOG_DIR)
232+
top_build_dir = mktempdir(; cleanup = false)
233+
234+
for name in solvers
235+
cfg = SOLVER_CONFIGS[name]
236+
generate_test_project(name, cfg)
237+
end
238+
239+
for name in solvers
240+
@testset "Trim: $(SOLVER_CONFIGS[name].display_name)" begin
241+
build_dir = joinpath(top_build_dir, name)
242+
mkpath(build_dir)
243+
244+
exe_path, n_errors = compile_solver(name, build_dir)
245+
@test n_errors == 0
246+
@test isfile(exe_path)
247+
248+
if isfile(exe_path) && n_errors == 0
249+
validate_output(exe_path, name, build_dir)
250+
end
251+
end
252+
end
253+
end
254+
255+
run_trim_tests()

0 commit comments

Comments
 (0)