Skip to content
Open
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: 16 additions & 0 deletions src/schema.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions src/storage.jl
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Binary file added test/fixtures/pre_gpu_fields_v1_pregpu0.jld2
Binary file not shown.
157 changes: 157 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ------------------------------------------------------------------ #
Expand Down
Loading