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
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ option(AL_PYTHON_BINDINGS "Build Python bindings" OFF)

option(AL_BUILD_TESTS "Build the C/C++ low-level ABI smoke tests (tests/)" OFF)

# Independent of AL_BUILD_TESTS: a benchmark "failing" means slower, not
# wrong, so it never gates the correctness build (NORTH_STAR.md §8.4).
option(AL_BUILD_BENCHMARKS "Build the Google Benchmark harness (benchmarks/)" OFF)

# The fixtures/al_contract_fixtures INTERFACE library itself is always
# available (see below) so it is genuinely usable outside AL_BUILD_TESTS; this
# only gates the throwaway demonstration executable (issue #51 acceptance
Expand Down Expand Up @@ -246,6 +250,13 @@ if( AL_BUILD_TESTS )
add_subdirectory(tests)
endif()

# Benchmark harness (opt-in, off in the ordinary build)
# ##############################################################################
if( AL_BUILD_BENCHMARKS )
enable_testing()
add_subdirectory(benchmarks)
endif()

# Install
# ##############################################################################

Expand Down
23 changes: 23 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@
"AL_BUILD_MDSPLUS_MODELS": "ON",
"CMAKE_BUILD_TYPE": "Release"
}
},
{
"name": "benchmarks",
"displayName": "Benchmark harness build (Release)",
"description": "Google Benchmark harness in benchmarks/ (issue #52); independent of AL_BUILD_TESTS and off in the ordinary build. A benchmark 'failing' means slower, not wrong, so this never gates the correctness suite.",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build-benchmarks",
"cacheVariables": {
"AL_BUILD_BENCHMARKS": "ON",
"AL_PYTHON_BINDINGS": "OFF",
"CMAKE_BUILD_TYPE": "Release"
}
}
],
"buildPresets": [
Expand All @@ -55,6 +67,10 @@
"name": "mdsplus",
"configurePreset": "mdsplus",
"targets": [ "contract_tests" ]
},
{
"name": "benchmarks",
"configurePreset": "benchmarks"
}
],
"testPresets": [
Expand Down Expand Up @@ -113,6 +129,13 @@
},
"output": { "outputOnFailure": true },
"execution": { "noTestsAction": "error" }
},
{
"name": "benchmarks",
"displayName": "Benchmark harness smoke: proves build+run+report, asserts no timing value",
"configurePreset": "benchmarks",
"output": { "outputOnFailure": true },
"execution": { "noTestsAction": "error" }
}
]
}
114 changes: 114 additions & 0 deletions benchmarks/BASELINE_CAPTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Capturing a baseline

This documents the repeatable procedure behind `capture_baseline.py` (issue
#55): turning a run of `al_benchmarks` into a metadata-tagged, committable
JSON artifact under [`benchmarks/baselines/`](baselines/README.md).

The script wraps whatever benchmarks are registered — it does not know or
care which scenarios exist, so this procedure does not change as
`bench_*.cpp` grows beyond the current whole-IDS-read operation.

## Laptop (development / smoke — throwaway numbers)

Any machine, any state of the working tree. These numbers exist to prove the
capture flow works, never to be compared against anything.

```sh
cmake --preset benchmarks
cmake --build --preset benchmarks

python3 benchmarks/capture_baseline.py \
--binary build-benchmarks/benchmarks/al_benchmarks \
--machine-id "laptop-$(hostname -s)" \
-- --benchmark_min_time=1x
```

`--benchmark_min_time=1x` caps every registered benchmark at one iteration —
the same flag the CTest smoke check (`benchmark_smoke_run`) uses — so this
finishes in well under a second regardless of how many scenarios exist.
Drop it to get real repetition counts (slower, and still not a measurement
of record without the cluster's isolation).

The script prints the metadata it captured (OS, CPU, compiler, HDF5 version,
filesystem, commit SHA/dirty flag, `slurm_partition: null`) and the path it
wrote to. On a laptop `commit_dirty` will often be `true` — that's expected
and is exactly why this file is a smoke artifact, not a baseline of record.

On macOS, the script detects the Apple chip through `system_profiler` when the
usual CPU-brand `sysctl` is unavailable. If automatic detection is blocked in
your environment, pass the precise model explicitly, for example
`--cpu-model "Apple M5 Pro"`.

## ITER SDCC cluster (measurement of record)

Run on a clean checkout of `develop` at a known commit, before any P1–P6
hot-path change lands (PRD #50: the baseline becomes unrecoverable the
moment optimization starts).

1. **Allocate an exclusive node** on the partition your supervisor names as
production-representative — the partition is a runtime parameter, never
hard-coded into the script or this doc:

```sh
salloc --exclusive --partition=<PARTITION> --time=00:30:00
```

`$SLURM_JOB_PARTITION` is then set for the rest of the allocation, and
`capture_baseline.py` picks it up automatically (or pass
`--slurm-partition <PARTITION>` explicitly).

2. **Load the toolchain modules**:

```sh
module load foss-2023b googletest
```

`foss-2023b` provides the compiler and the HDF5 the benchmark links
against; `googletest` is needed because the harness links
`al_contract_fixtures` (shared with the contract suite).

3. **Configure and build in Release**, on GPFS (`/mnt/HPC_T2`), not on a
login-node home directory:

```sh
cd /mnt/HPC_T2/<your-workspace>/IMAS-Core
cmake --preset benchmarks
cmake --build --preset benchmarks
```

4. **Capture**, recording the filesystem explicitly (auto-detection is
best-effort and GPFS mounts don't always self-report cleanly) and a
`machine-id` that names the cluster, not the specific login/compute node:

```sh
python3 benchmarks/capture_baseline.py \
--binary build-benchmarks/benchmarks/al_benchmarks \
--machine-id sdcc-gpfs \
--filesystem gpfs
```

No `--benchmark_min_time` here — let each scenario run its full
repetition count so the committed baseline reports a real distribution
(median, mean, stddev), per PRD #50 user story 8.

5. **Verify before committing**: the script refuses (unless `--force`) to
overwrite a baseline already committed for this `machine-id` + commit SHA,
and warns to stderr if the working tree is dirty or the build isn't
Release. Resolve any such warning rather than force past it for a
measurement of record.

6. **Commit** the resulting file under `benchmarks/baselines/sdcc-gpfs/` —
see [`baselines/README.md`](baselines/README.md) for the layout and how
later runs compare against it.

## What gets recorded either way

`captured_at`, `machine_id`, `os`, `cpu_model`, `compiler` (+ version),
`compiler_flags`, `build_type`, `hdf5_version`, `filesystem`,
`slurm_partition` (`null` off-cluster), `commit_sha`, `commit_dirty` — spliced
into the Google Benchmark JSON's `context` block as `imas_baseline_metadata`.
Compiler/flags/HDF5 version are read from the build directory's
`CMakeCache.txt` (found by walking up from `--binary`, or pass `--build-dir`
explicitly if your build layout doesn't nest the binary under it). Machine IDs
are normalized to lowercase letters, digits, and dashes, ensuring every
artifact remains inside the documented baseline layout.
103 changes: 103 additions & 0 deletions benchmarks/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
project( al-benchmarks )

########################################################################################################################
# Google Benchmark, pinned via FetchContent (NORTH_STAR.md §8.4).
#
# Unlike tests/contract's GoogleTest dependency, there is no find_package
# fallback here: the benchmark harness is a standalone tree gated by its own
# option (AL_BUILD_BENCHMARKS), and must not rely on a cluster module (e.g.
# the ITER SDCC `googlebenchmark` module) -- FetchContent keeps the pin
# identical on every machine the harness runs on. Pinned to the immutable
# commit for release v1.9.5 (a tag can be re-pointed).

include(FetchContent)
FetchContent_Declare(
googlebenchmark
GIT_REPOSITORY https://github.com/google/benchmark.git
GIT_TAG 192ef10025eb2c4cdd392bc502f0c852196baa48
)
set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "" FORCE)
set(BENCHMARK_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "" FORCE)
set(BENCHMARK_ENABLE_WERROR OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googlebenchmark)

find_package(Python3 COMPONENTS Interpreter REQUIRED)

########################################################################################################################
# The benchmark executable.
#
# bench_write_read.cpp is the original tracer bullet: a single-leaf
# write-once-then-read on a tiny inline fixture, against the in-RAM Memory
# backend, needing no filesystem or optional backend.
#
# bench_equilibrium_read.cpp is the real P1 headline workload (issue #53):
# whole-IDS equilibrium read, reusing equilibrium_seed.h (issue #51) via the
# al_contract_fixtures INTERFACE library below. It registers the same
# operation against both HDF5 (needs the filesystem -- std::filesystem temp
# dirs) and, as a zero-I/O control (issue #54), the in-RAM Memory backend.
#
# bench_equilibrium_write.cpp is the write-side counterpart (issue #59): the
# same equilibrium_seed.h fixture, written repeatedly instead of read,
# against both backends, sharing bench_equilibrium_read.cpp's per-backend
# boilerplate via bench_common.h (issue #58).
#
# bench_timeslice_element_read.cpp (issue #60) is the shared "before"
# baseline for the future P2 (al_read_subtree) and P3 (slice-mode buffering)
# optimizations (NORTH_STAR.md §7): one time_slice element's full leaf set,
# read node-by-node by index, against both HDF5 and Memory.
#
# bench_aos_traversal.cpp (issue #61) is the "before" baseline for P1/P4's
# round-trip-count reduction (NORTH_STAR.md §7): every time_slice element,
# every field, visited via al_iterate_over_arraystruct, against both HDF5 and
# Memory.
#
# bench_main.cpp holds the one BENCHMARK_MAIN() shared by every bench_*.cpp
# TU in this executable.

add_executable( al_benchmarks
bench_write_read.cpp
bench_equilibrium_read.cpp
bench_equilibrium_write.cpp
bench_timeslice_element_read.cpp
bench_aos_traversal.cpp
bench_main.cpp )
target_link_libraries( al_benchmarks PRIVATE al_contract_fixtures benchmark::benchmark )
add_dependencies( al_benchmarks al )
target_compile_features( al_benchmarks PRIVATE cxx_std_17 )

if(NOT WIN32 AND NOT APPLE)
# stdc++fs is only needed on Linux with older GCC (std::filesystem use in
# bench_equilibrium_read.cpp), mirroring fixtures/CMakeLists.txt's smoke target.
target_link_libraries( al_benchmarks PRIVATE stdc++fs )
endif()

########################################################################################################################
# CTest smoke entry.
#
# Proves the harness builds, links libal, drives the C ABI, and produces
# parseable Google Benchmark JSON -- it asserts none of the reported timing
# values (a benchmark "failing" means slower, not wrong; NORTH_STAR.md §8.4).
# --benchmark_min_time=1x caps every registered benchmark at exactly one
# iteration, so the smoke run stays fast regardless of what benchmarks are
# added later.
#
# Follows the FIXTURES_SETUP/FIXTURES_REQUIRED shape tests/CMakeLists.txt uses
# for its own smoke registration: one test produces the JSON file, then CMake's
# built-in JSON parser validates it without adding another runtime dependency.

set( _smoke_json ${CMAKE_CURRENT_BINARY_DIR}/benchmark_smoke.json )

add_test( NAME benchmark_smoke_run
COMMAND al_benchmarks --benchmark_min_time=1x
--benchmark_out=${_smoke_json}
--benchmark_out_format=json )
set_tests_properties( benchmark_smoke_run PROPERTIES FIXTURES_SETUP BENCHMARK_SMOKE_JSON )

add_test( NAME benchmark_smoke_validate
COMMAND ${CMAKE_COMMAND} -DINPUT=${_smoke_json}
-P ${CMAKE_CURRENT_SOURCE_DIR}/validate_benchmark_json.cmake )
set_tests_properties( benchmark_smoke_validate PROPERTIES FIXTURES_REQUIRED BENCHMARK_SMOKE_JSON )

add_test( NAME baseline_capture_unit
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_capture_baseline.py )
67 changes: 67 additions & 0 deletions benchmarks/baselines/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Committed baseline layout

```
benchmarks/baselines/<machine-id>/<commit-sha>.json
```

Each file is the raw Google Benchmark JSON that `al_benchmarks` emitted for
that run — Google Benchmark's own top-level `context` (date, host_name,
num_cpus, ...) and `benchmarks` (one entry per `BENCHMARK()` registration,
repetitions, and `_mean`/`_median`/`_stddev`/`_cv` rows) — with one field
spliced into `context`:

```json
{
"imas_baseline_metadata": {
"captured_at": "2026-07-20T09:50:42.347134+00:00",
"machine_id": "sdcc-gpfs",
"os": "Linux-5.14.0-x86_64",
"cpu_model": "Intel(R) Xeon(R) Gold 6248R",
"compiler": "GNU 12.3.0",
"compiler_flags": "-O3 -DNDEBUG",
"build_type": "Release",
"hdf5_version": "1.14.3",
"filesystem": "gpfs",
"slurm_partition": "iter",
"commit_sha": "7246824127a3339e6864f91238eb434a8ebb2afc",
"commit_dirty": false
}
}
```

(the object above nests under `context.imas_baseline_metadata`; only that
field is shown, not the full committed file)

`imas_baseline_metadata` is what makes two files comparable — or tells you why
they aren't (different `compiler_flags`, different `filesystem`, etc). See
[`../BASELINE_CAPTURE.md`](../BASELINE_CAPTURE.md) for how these files are
produced.

## Naming

- `<machine-id>` is the normalized value of `--machine-id` (default: the
sanitized hostname). It contains only lowercase letters, digits, and dashes.
Use a stable,
descriptive id for anything meant to be a measurement of record, e.g.
`sdcc-gpfs` for the ITER SDCC cluster, rather than a raw login-node
hostname that might change.
- `<commit-sha>` is the full `git rev-parse HEAD` of the run. A dirty working
tree is still captured (for laptop iteration) but is flagged
`commit_dirty: true` in the metadata — that file is not reproducible from
the SHA alone and should not be treated as a measurement of record.

## Comparing two baselines

There is no comparison tool here by design (PRD #50: "Not tested: the
numeric results themselves... a manual/analysis step over the committed
JSON, not a CI gate"). To compare, match `benchmarks[].name` between two
files and look at the `_mean`/`_median` rows' `real_time`/`cpu_time`, e.g.
with `jq`:

```sh
jq '.benchmarks[] | select(.name | endswith("_mean")) | {name, real_time, cpu_time}' \
benchmarks/baselines/sdcc-gpfs/<old-sha>.json

jq '.benchmarks[] | select(.name | endswith("_mean")) | {name, real_time, cpu_time}' \
benchmarks/baselines/sdcc-gpfs/<new-sha>.json
```
Loading
Loading