diff --git a/src/schema.jl b/src/schema.jl index b5b93f9..8122a43 100644 --- a/src/schema.jl +++ b/src/schema.jl @@ -168,6 +168,18 @@ struct BenchmarkResult # peak_rss_delta_bytes when triaging GPU-bound benchmarks that exhibit # unexpected host-side pressure. oom_margin_bytes::Int + # GPU device-memory deltas across the solve. Optional — `nothing` means the + # device axis was not measured for this result (CPU-arm solves, and any + # result serialized before these fields existed, deserialize as `nothing`). + # A concrete `0` is distinct: it means the device WAS measured and the delta + # was zero. Field names mirror the host `total_allocations_bytes` convention. + # gpu_allocations_bytes: cumulative device allocation over the timed solve + # (the deterministic GPU analogue of host allocations). Populated by the + # GPU-arms slice (Piccolissimo #254); peak-device sampling is out of scope. + # gpu_live_bytes: post-solve live device bytes (device analogue of + # `live_heap_delta_bytes`'s retained-heap intent). + gpu_allocations_bytes::Union{Nothing,Int} + gpu_live_bytes::Union{Nothing,Int} # Options solver_options::Dict{Symbol,Any} # Per-backend iteration breakdown. Solver iteration structures are NOT @@ -216,6 +228,8 @@ function BenchmarkResult(; peak_rss_delta_bytes::Int = 0, live_heap_delta_bytes::Int = 0, oom_margin_bytes::Int = 0, + gpu_allocations_bytes::Union{Nothing,Int} = nothing, + gpu_live_bytes::Union{Nothing,Int} = nothing, solver_options::Dict{Symbol,Any}, iteration_counts::Dict{Symbol,Int} = Dict{Symbol,Int}(), convergence::Union{Nothing,ConvergenceCriterion} = nothing, @@ -248,6 +262,8 @@ function BenchmarkResult(; peak_rss_delta_bytes, live_heap_delta_bytes, oom_margin_bytes, + gpu_allocations_bytes, + gpu_live_bytes, solver_options, iteration_counts, convergence, diff --git a/src/storage.jl b/src/storage.jl index a4b31ca..d29dc4a 100644 --- a/src/storage.jl +++ b/src/storage.jl @@ -1,5 +1,66 @@ using JLD2 +# ------------------------------------------------------------------ # +# Backward-compatibility upgrade shim for BenchmarkResult +# ------------------------------------------------------------------ # +# When the in-memory `BenchmarkResult` struct gains fields that a committed +# JLD2 blob was written without, JLD2 cannot map the on-disk layout onto the +# current type and hands back a `JLD2.ReconstructedMutable{:BenchmarkResult}` +# (a field-name/-value bag) instead of a real `BenchmarkResult`. `load_results` +# then tries to `convert` that bag into `Vector{BenchmarkResult}` and fails. +# +# This `convert` method is that upgrade path: it reads each field the old blob +# does carry and defaults any field the blob lacks. Concretely it lets any +# result serialized before the GPU device-memory fields existed +# (`gpu_allocations_bytes`, `gpu_live_bytes`) load with those fields defaulted +# to `nothing` ("device axis not measured"). It is written generically over the +# reconstructed field set so it also tolerates future additive schema changes. + +# Read property `name` off a reconstructed bag if present, else `default`. +_rc_get(rc, name::Symbol, default) = + name in propertynames(rc) ? getproperty(rc, name) : default + +function Base.convert( + ::Type{BenchmarkResult}, + rc::JLD2.ReconstructedMutable{:BenchmarkResult}, +) + return BenchmarkResult(; + package = rc.package, + package_version = rc.package_version, + commit = rc.commit, + benchmark_name = rc.benchmark_name, + N = rc.N, + state_dim = rc.state_dim, + control_dim = rc.control_dim, + n_constraints = rc.n_constraints, + n_variables = rc.n_variables, + wall_time_s = rc.wall_time_s, + iterations = rc.iterations, + objective_value = rc.objective_value, + constraint_violation = rc.constraint_violation, + solver_status = rc.solver_status, + solver = rc.solver, + total_allocations_bytes = rc.total_allocations_bytes, + total_allocs_count = rc.total_allocs_count, + gc_time_ns = rc.gc_time_ns, + gc_count = rc.gc_count, + gc_full_count = rc.gc_full_count, + # Optional/defaulted fields — tolerate blobs predating each of them. + peak_rss_delta_bytes = _rc_get(rc, :peak_rss_delta_bytes, 0), + live_heap_delta_bytes = _rc_get(rc, :live_heap_delta_bytes, 0), + oom_margin_bytes = _rc_get(rc, :oom_margin_bytes, 0), + gpu_allocations_bytes = _rc_get(rc, :gpu_allocations_bytes, nothing), + gpu_live_bytes = _rc_get(rc, :gpu_live_bytes, nothing), + solver_options = rc.solver_options, + iteration_counts = _rc_get(rc, :iteration_counts, Dict{Symbol,Int}()), + convergence = _rc_get(rc, :convergence, nothing), + julia_version = rc.julia_version, + timestamp = rc.timestamp, + runner = rc.runner, + n_threads = rc.n_threads, + ) +end + """ save_results(dir, name, results::Vector{BenchmarkResult}) -> String diff --git a/test/fixtures/pre_gpu_fields_v1_pregpu0.jld2 b/test/fixtures/pre_gpu_fields_v1_pregpu0.jld2 new file mode 100644 index 0000000..bbe10fc Binary files /dev/null and b/test/fixtures/pre_gpu_fields_v1_pregpu0.jld2 differ diff --git a/test/runtests.jl b/test/runtests.jl index 8137535..65e4e29 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -178,6 +178,163 @@ using LinearAlgebra end end + @testset "GPU device-memory fields (schema + defaults)" begin + kw = ( + package = "Piccolissimo", + package_version = "0.3.0", + commit = "gpu0", + benchmark_name = "chain_x_gpu", + N = 100, + state_dim = 4, + control_dim = 2, + n_constraints = 50, + n_variables = 200, + wall_time_s = 1.23, + iterations = 30, + objective_value = 0.001, + constraint_violation = 1e-8, + solver_status = :Optimal, + solver = "Altissimo", + total_allocations_bytes = 1_000_000, + total_allocs_count = 5000, + gc_time_ns = 100_000, + gc_count = 3, + gc_full_count = 1, + solver_options = Dict{Symbol,Any}(:max_iter => 100), + julia_version = "1.12.0", + timestamp = DateTime(2026, 7, 1), + runner = "ci", + n_threads = 8, + ) + + # Omitted ⇒ default to nothing ("device axis not measured"), so CPU-arm + # results are unaffected. + br_cpu = BenchmarkResult(; kw...) + @test br_cpu.gpu_allocations_bytes === nothing + @test br_cpu.gpu_live_bytes === nothing + + # A concrete 0 is distinct from `nothing`: measured, and the delta was 0. + br_zero = BenchmarkResult(; kw..., gpu_allocations_bytes = 0, gpu_live_bytes = 0) + @test br_zero.gpu_allocations_bytes == 0 + @test br_zero.gpu_live_bytes == 0 + + # GPU arm carries real device-memory values. + br_gpu = BenchmarkResult(; + kw..., + gpu_allocations_bytes = 4_294_967_296, + gpu_live_bytes = 1_073_741_824, + ) + @test br_gpu.gpu_allocations_bytes == 4_294_967_296 + @test br_gpu.gpu_live_bytes == 1_073_741_824 + end + + @testset "GPU device-memory fields: JLD2 round-trip" begin + br = BenchmarkResult( + package = "Piccolissimo", + package_version = "0.3.0", + commit = "gpurt", + benchmark_name = "chain_x_gpu_roundtrip", + N = 75, + state_dim = 3, + control_dim = 1, + n_constraints = 40, + n_variables = 120, + wall_time_s = 2.5, + iterations = 50, + objective_value = 1e-4, + constraint_violation = 1e-9, + solver_status = :Optimal, + solver = "Altissimo", + total_allocations_bytes = 500_000, + total_allocs_count = 2500, + gc_time_ns = 50_000, + gc_count = 2, + gc_full_count = 0, + gpu_allocations_bytes = 8_589_934_592, + gpu_live_bytes = 2_147_483_648, + solver_options = Dict{Symbol,Any}(:tol => 1e-8), + julia_version = "1.12.0", + timestamp = DateTime(2026, 7, 1, 10, 30, 0), + runner = "ci", + n_threads = 8, + ) + + mktempdir() do dir + path = save_results(dir, "gpu_roundtrip", [br]) + loaded = load_results(path) + @test loaded isa Vector{BenchmarkResult} + r = loaded[1] + @test r.gpu_allocations_bytes == 8_589_934_592 + @test r.gpu_live_bytes == 2_147_483_648 + + # A result with defaulted (nothing) GPU fields round-trips too. + br_cpu = BenchmarkResult( + package = "DirectTrajOpt", + package_version = "0.9.0", + commit = "cpurt", + benchmark_name = "chain_x_cpu_roundtrip", + N = 10, + state_dim = 2, + control_dim = 1, + n_constraints = 20, + n_variables = 30, + wall_time_s = 0.5, + iterations = 5, + objective_value = 0.1, + constraint_violation = 1e-8, + solver_status = :Optimal, + solver = "Ipopt", + total_allocations_bytes = 100, + total_allocs_count = 10, + gc_time_ns = 0, + gc_count = 0, + gc_full_count = 0, + solver_options = Dict{Symbol,Any}(), + julia_version = "1.12.0", + timestamp = DateTime(2026, 7, 1), + runner = "ci", + n_threads = 1, + ) + path2 = save_results(dir, "cpu_roundtrip", [br_cpu]) + r2 = load_results(path2)[1] + @test r2.gpu_allocations_bytes === nothing + @test r2.gpu_live_bytes === nothing + end + end + + @testset "Backward-compat: pre-GPU-fields fixture still loads" begin + # `test/fixtures/pre_gpu_fields_v1_pregpu0.jld2` was serialized with the + # BenchmarkResult schema BEFORE the GPU device-memory fields existed. + # It must still load as a real Vector{BenchmarkResult}, with the new + # fields defaulted to `nothing`. This is the hard invariant: no committed + # baseline may ever fail to load after an additive schema change. + fixture = joinpath(@__DIR__, "fixtures", "pre_gpu_fields_v1_pregpu0.jld2") + @test isfile(fixture) + + loaded = load_results(fixture) + @test loaded isa Vector{BenchmarkResult} + @test length(loaded) == 1 + r = loaded[1] + @test r isa BenchmarkResult + + # Pre-existing fields survive the upgrade unchanged. + @test r.benchmark_name == "chain_x_pre_gpu_fields" + @test r.package == "DirectTrajOpt" + @test r.solver == "Altissimo" + @test r.total_allocations_bytes == 1_000_000 + @test r.peak_rss_delta_bytes == 2048 + @test r.live_heap_delta_bytes == -512 + @test r.oom_margin_bytes == 999 + @test r.iteration_counts == Dict(:outer => 30, :inner => 900) + @test r.convergence isa InfidelityConvergence + @test converged(r.convergence) == true + @test r.timestamp == DateTime(2026, 7, 1, 12, 0, 0) + + # The newly-added fields default cleanly for pre-change data. + @test r.gpu_allocations_bytes === nothing + @test r.gpu_live_bytes === nothing + end + # ------------------------------------------------------------------ # # Harness tests (Tasks 4–6) # ------------------------------------------------------------------ #