Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
dc61132
enzyme: exact Hessian of the composed local force map
krystophny Jun 14, 2026
1d1fedd
apply pre-commit formatting (ruff, docformatter, clang-format)
krystophny Jun 14, 2026
7441566
bazel: declare force-chain kernel headers in ideal_mhd_model (sandbox…
krystophny Jun 14, 2026
f1ad6d6
ci: skip benchmark result upload on fork PRs (token is read-only)
krystophny Jun 14, 2026
3e9a980
ci: build VMEC2000 from source so the compat test runs on numpy 2
krystophny Jun 14, 2026
839d0fc
test: skip vmecpp-only indata fields in the VMEC2000 compat subset
krystophny Jun 14, 2026
61a2e91
ci: sync VMEC2000-from-source build, benchmark fork guard, abseil com…
krystophny Jun 15, 2026
e65e1e9
Merge remote-tracking branch 'upstream/main' into HEAD
krystophny Jun 15, 2026
74e4d30
ideal_mhd_model: hoist ForcesToFourier scratch out of the inner loop
krystophny Jun 16, 2026
8ec3835
ideal_mhd_model: mark Jacobian metric kernel buffers __restrict
krystophny Jun 16, 2026
06b37df
output_quantities: compare jcuru/jcurv at a looser opt-in tolerance
krystophny Jun 16, 2026
002d79d
Merge branch 'mhdforce-kernel' into local-force-hessian
krystophny Jun 24, 2026
c8fefc2
enzyme: drop timing-dependent benchmark from local force Hessian test
krystophny Jun 27, 2026
f2d96ea
ideal_mhd_model: drop shadowed dead scratch in toroidal force DFT
krystophny Jun 27, 2026
33460cd
Merge branch 'mhdforce-kernel' into local-force-hessian
krystophny Jul 8, 2026
10938d7
ideal_mhd_model: share the contravariant-field kernel (bsupu, bsupv) …
krystophny Jul 8, 2026
d1c2328
Add C++ Google Benchmark microbenchmarks for critical hot functions (…
jurasic-pf Jul 8, 2026
c55210c
ideal_mhd_model: share the covariant-field kernel (bsubu, bsubv) (#570)
krystophny Jul 8, 2026
9b33766
Merge branch 'main' into local-force-hessian
jurasic-pf Jul 8, 2026
d203333
ideal_mhd_model: share the magnetic-pressure kernel (#571)
krystophny Jul 8, 2026
a279e5f
Merge branch 'main' into local-force-hessian
jurasic-pf Jul 8, 2026
acd99d5
ideal_mhd_model: share the MHD force-density kernel (6th/last) (#572)
krystophny Jul 8, 2026
b717f64
Merge branch 'main' into local-force-hessian
jurasic-pf Jul 8, 2026
99d2e45
Fix current density in magnetic field visualization example (#607)
WenyinWei Jul 8, 2026
b23efc2
Merge branch 'main' into local-force-hessian
jurasic-pf Jul 8, 2026
ca01569
enzyme: exact Hessian of the composed local force map (#573)
krystophny Jul 8, 2026
8881635
ideal_mhd_model: share the hybrid lambda-force kernel (#574)
krystophny Jul 8, 2026
f36757d
ideal_mhd_model: share the constraint-force kernels (#575)
krystophny Jul 8, 2026
9de6072
Merge fast_poloidal and fast_toroidal Fourier bases into one template…
CharlesCNorton Jul 8, 2026
902d930
pybind: expose the unpreconditioned internal-basis gradient (#577)
krystophny Jul 8, 2026
cd3dfc2
Clean up benchmark on demandplots (#615)
jurasic-pf Jul 8, 2026
7c81177
Report less, more targeted results in FFT bench (#617)
jurasic-pf Jul 9, 2026
686bd26
Honor iteration_style=parvmec in the native solver (#612)
CharlesCNorton Jul 9, 2026
e0f1985
Abseil status handling for mgrid errors (#613)
jurasic-pf Jul 9, 2026
2449ddc
pybind: Hessian-vector product inside VMEC++ + internal Newton-Krylov…
krystophny Jul 9, 2026
b05e2da
3d external optimizer example (#618)
jurasic-pf Jul 9, 2026
4a9e717
Remove explicit test of an example script (#622)
jurasic-pf Jul 9, 2026
2e4c22d
Refactor Fourier-resolution continuation into vmecpp.run() (#619)
clazzati-pf Jul 9, 2026
271f3bf
Fix transform hot-loop regression from Eigen3 migration (#621)
jurasic-pf Jul 9, 2026
ec3ee09
Fix typos in documentation (#627)
CharlesCNorton Jul 9, 2026
4f40e83
Restore external optimizer behavioral tests (#623)
krystophny Jul 9, 2026
f0cb35a
Make raw VMEC forces history independent (#626)
krystophny Jul 10, 2026
e957104
Merge remote-tracking branch 'upstream/main' into HEAD
krystophny Jul 10, 2026
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
145 changes: 135 additions & 10 deletions .github/actions/run-benchmarks/action.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
name: Run VMEC++ benchmarks
description: >
Install VMEC++ and run the pytest benchmark suite in a fixed environment,
then store or compare the results. This is the single source of truth for the
benchmark environment so the pull-request and main/docs runs cannot drift
apart; the only intended difference between callers is whether results are
written back to the benchmark-runs branch (auto-push) and whether a regression
is commented on the PR (comment-on-alert).
Install VMEC++ and run the benchmark suites in a fixed environment, then store
or compare the results. Two suites run here: the Python pytest end-to-end
benchmarks and the C++ Google Benchmark function-level microbenchmarks. This is
the single source of truth for the benchmark environment so the pull-request
and main/docs runs cannot drift apart; the only intended difference between
callers is whether results are written back to the benchmark-runs branch
(auto-push) and whether a regression is commented on the PR (comment-on-alert).

inputs:
auto-push:
Expand All @@ -32,30 +33,154 @@ runs:
sudo apt-get update
sudo apt-get install -y build-essential cmake libnetcdf-dev liblapack-dev libomp-dev libhdf5-dev

- name: Mount bazel cache
uses: actions/cache@v4
with:
path: "~/.cache/bazel"
key: bazel-bench

- name: Install vmecpp with benchmark dependencies
shell: bash
run: |
pip install uv
uv pip install --system -v .[benchmark]

- name: Run benchmarks
# --- Python end-to-end benchmarks (pytest-benchmark) ---

- name: Run Python benchmarks
shell: bash
run: |
export OMP_WAIT_POLICY=passive
pytest benchmarks/test_benchmarks.py --benchmark-json=benchmark_results.json

- name: Store / compare benchmark result
# github-action-benchmark's "pytest" tool always plots pytest-benchmark's
# `ops` field as iter/sec, but the C++ suite emits seconds.
# We convert both to the same format, so plotting is the same.
- name: Convert Python benchmark result to seconds
shell: bash
run: |
python3 benchmarks/to_seconds_json.py pytest benchmark_results.json benchmark_results_seconds.json

- name: Store / compare Python benchmark result
# Skip the result upload/compare for fork PRs: their GITHUB_TOKEN is
# read-only, so comment-on-alert/auto-push hit 'Resource not accessible
# by integration'. The benchmarks still run above; only the write-back is
# skipped for forks. Main/docs runs (auto-push) always store.
if: ${{ inputs.auto-push == 'true' || github.event.pull_request.head.repo.full_name == github.repository }}
uses: benchmark-action/github-action-benchmark@v1.21.0
with:
tool: "pytest"
output-file-path: benchmark_results.json
tool: "customSmallerIsBetter"
output-file-path: benchmark_results_seconds.json
gh-pages-branch: benchmark-runs
benchmark-data-dir-path: benchmarks
alert-threshold: "150%"
fail-on-alert: false
auto-push: ${{ inputs.auto-push }}
comment-on-alert: ${{ inputs.comment-on-alert }}
github-token: ${{ inputs.github-token }}

# --- C++ function-level microbenchmarks (Google Benchmark) ---
#
# Builds the four benchmark cc_binary targets, runs each with
# --benchmark_format=json, and merges their output into a single
# cpp_benchmark_results.json (schema: {"context": ..., "benchmarks": [...]})
# for the conversion step below. Entries with "error_occurred" (e.g. an
# FFT-path benchmark skipped because no FFTX codelet exists for that
# resolution) are dropped: they carry no valid timing to track.

- name: Run C++ microbenchmarks
shell: bash
run: |
set -euo pipefail

TARGETS=(
"//vmecpp/vmec/ideal_mhd_model:fft_toroidal_bench"
"//vmecpp/vmec/ideal_mhd_model:dealias_constraint_force_bench"
"//vmecpp/free_boundary/laplace_solver:laplace_solver_bench"
"//vmecpp/vmec/output_quantities:output_quantities_bench"
)

OUT_FILE="${GITHUB_WORKSPACE}/cpp_benchmark_results.json"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT

cd src/vmecpp/cpp

# --config=perf: optimized build with frame pointers and symbols
# retained, matching the existing .bazelrc convention for
# performance work.
bazel build --config=perf -- "${TARGETS[@]}"

# Run each target. `bazel run` so runfiles (e.g. test_data for the
# output-quantities benchmark) are available in the working
# directory. --benchmark_min_time keeps CI runtime bounded while
# still averaging enough iterations for a stable measurement.
for target in "${TARGETS[@]}"; do
name="${target##*:}"
bazel run --config=perf -- "${target}" \
--benchmark_min_time=0.2s \
--benchmark_format=json \
--benchmark_out="${TMP_DIR}/${name}.json"
done

python3 - "${TMP_DIR}" "${OUT_FILE}" <<'PY'
import glob
import json
import os
import sys

tmp_dir, out_file = sys.argv[1], sys.argv[2]
merged = {"context": None, "benchmarks": []}
skipped = 0
for path in sorted(glob.glob(os.path.join(tmp_dir, "*.json"))):
with open(path) as f:
data = json.load(f)
if merged["context"] is None:
merged["context"] = data.get("context")
for bench in data.get("benchmarks", []):
if bench.get("error_occurred"):
skipped += 1
continue
merged["benchmarks"].append(bench)

with open(out_file, "w") as f:
json.dump(merged, f, indent=2)

print(f"Merged {len(merged['benchmarks'])} benchmark entries "
f"({skipped} skipped/errored).")
PY

# github-action-benchmark's "pytest" tool always plots pytest-benchmark's
# `ops` field as iter/sec, but the C++ suite emits seconds.
# We convert both to the same format, so plotting is the same.
- name: Convert C++ benchmark result to seconds
shell: bash
run: |
python3 benchmarks/to_seconds_json.py googlecpp cpp_benchmark_results.json cpp_benchmark_results_seconds.json

- name: Store / compare C++ benchmark result
# Same fork-PR skip rationale as the Python store step above.
#
# Runs sequentially after the Python store step (composite-action steps
# are serial), so the two auto-push writes to benchmark-runs never race.
# A distinct suite name keeps the C++ results under their own key in
# data.js (window.BENCHMARK_DATA.entries) so they never overwrite the
# Python "Benchmark" suite, even though both share benchmark-data-dir-path.
#
# skip-fetch-gh-pages: the Python store step above already fetched the
# benchmark-runs branch into a local ref and (in compare mode) advanced
# it, so a second `git fetch benchmark-runs:benchmark-runs` here is
# rejected as non-fast-forward. Reuse the already-fetched local ref
# instead of re-fetching.
if: ${{ inputs.auto-push == 'true' || github.event.pull_request.head.repo.full_name == github.repository }}
uses: benchmark-action/github-action-benchmark@v1.21.0
with:
name: "C++ Microbenchmarks"
tool: "customSmallerIsBetter"
output-file-path: cpp_benchmark_results_seconds.json
gh-pages-branch: benchmark-runs
benchmark-data-dir-path: benchmarks
skip-fetch-gh-pages: true
alert-threshold: "150%"
fail-on-alert: false
auto-push: ${{ inputs.auto-push }}
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/clang_tidy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ jobs:
apt_packages: libhdf5-dev, liblapack-dev, libnetcdf-dev, gfortran, python3-dev, libomp-dev
build_dir: build
cmake_command: cmake -B build && cmake -DCMAKE_EXPORT_COMPILE_COMMANDS:STRING=ON build
# bazel-built test files have no cmake compile_commands entry, so
# clang-tidy cannot resolve <gtest/gtest.h>; exclude them from review
exclude: "./src/vmecpp/cpp/bazel-*,./src/vmecpp/cpp/external,./src/vmecpp/cpp/third_party,*_test.cc"
# bazel-built test and benchmark files have no cmake compile_commands
# entry, so clang-tidy cannot resolve <gtest/gtest.h> /
# <benchmark/benchmark.h> (both are Bazel-only deps); exclude them
# from review
exclude: "./src/vmecpp/cpp/bazel-*,./src/vmecpp/cpp/external,./src/vmecpp/cpp/third_party,*_test.cc,*_bench.cc"
# turn off "LGTM" comments: we only want pings about warnings/errors
lgtm_comment_body: ""
# If there are any comments, fail the check
Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/full_validation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Full V&V against reference VMEC

# Runs the full Verification & Validation suite from proximafusion/vmecpp-validation
# (all ~219 input configurations against reference Fortran VMEC2000), using the
# vmecpp version built from this commit rather than the version pinned on PyPI/GitHub.
# This is a lot more exhaustive (and slower) than the "short" validation that
# vmecpp-validation itself runs on its own PRs, so it is not run on every push here.
on:
workflow_dispatch:
push:
branches:
- main

concurrency:
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
cancel-in-progress: ${{ github.ref_name != 'main' }}

jobs:
full-validation:
name: Run full VMEC++ validation
runs-on: ubuntu-22.04
# The full parameter scan runs ~219 configurations, each computing a reference
# wout with Fortran VMEC2000 in Docker plus one with VMEC++: budget generously.
timeout-minutes: 360
steps:
- name: Check out VMEC++
uses: actions/checkout@v4
with:
path: vmecpp
lfs: true

- name: Check out vmecpp-validation
uses: actions/checkout@v4
with:
repository: proximafusion/vmecpp-validation
path: vmecpp-validation
lfs: true

- uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install required system packages for Ubuntu
run: |
sudo apt-get update && sudo apt-get install -y \
build-essential cmake libnetcdf-dev liblapack-dev liblapacke-dev libopenmpi-dev \
libomp-dev libeigen3-dev nlohmann-json3-dev libhdf5-dev

- name: Install vmecpp-validation's Python requirements
run: |
cd vmecpp-validation
# Install everything except vmecpp itself: we build and install vmecpp
# from this commit below instead of the version pinned in requirements.txt.
grep -v '^vmecpp@' requirements.txt > requirements.no-vmecpp.txt
python -m pip install -r requirements.no-vmecpp.txt

- name: Install VMEC++ from this commit
run: python -m pip install ./vmecpp

- name: Run full validation
working-directory: vmecpp-validation
run: python validate_vmec.py

- name: Upload V&V results
if: always()
uses: actions/upload-artifact@v4
with:
name: vnvresults
path: vmecpp-validation/vnvresults_*/
retention-days: 30
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

[![CI](https://github.com/proximafusion/vmecpp/actions/workflows/tests.yaml/badge.svg)](https://github.com/proximafusion/vmecpp/actions/workflows/tests.yaml)
[![C++ core tests](https://github.com/proximafusion/vmecpp/actions/workflows/test_bazel.yaml/badge.svg)](https://github.com/proximafusion/vmecpp/actions/workflows/test_bazel.yaml)
[![Full V&V against reference VMEC](https://github.com/proximafusion/vmecpp/actions/workflows/full_validation.yaml/badge.svg)](https://github.com/proximafusion/vmecpp/actions/workflows/full_validation.yaml)
[![Publish wheels to PyPI](https://github.com/proximafusion/vmecpp/actions/workflows/pypi_publish.yml/badge.svg)](https://github.com/proximafusion/vmecpp/actions/workflows/pypi_publish.yml)

VMEC++ is a Python-friendly, from-scratch reimplementation in C++ of the Variational Moments Equilibrium Code (VMEC),
Expand Down Expand Up @@ -315,6 +316,10 @@ The single-thread runtimes as well as the contents of the "wout" file produced b
The full validation test can be found at https://github.com/proximafusion/vmecpp-validation, including a set of sensible input configurations,
parameter scan values and tolerances that make the comparison pass. See that repo for more information.

This full validation (~219 input configurations) is run against every commit to `main` and can also be triggered
on demand from the [Full V&V against reference VMEC](https://github.com/proximafusion/vmecpp/actions/workflows/full_validation.yaml) workflow
(click "Run workflow"). It builds `vmecpp` from the corresponding commit rather than using the version pinned by `vmecpp-validation`.

## Differences with respect to PARVMEC/VMEC2000

VMEC++:
Expand Down
89 changes: 89 additions & 0 deletions benchmarks/to_seconds_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# SPDX-FileCopyrightText: 2024-present Proxima Fusion GmbH <info@proximafusion.com>
#
# SPDX-License-Identifier: MIT
"""Convert pytest-benchmark or Google Benchmark JSON to seconds.

github-action-benchmark's built-in "pytest" and "googlecpp" tool parsers plot whatever
value each framework happens to report natively -- pytest-benchmark's "iter/sec" and
Google Benchmark's raw per-iteration time in its own time_unit (commonly nanoseconds).
Charting those side by side, or even a single suite whose time_unit isn't fixed,
requires the reader (or the chart's JS) to know and convert between units.

This script normalizes both to plain wall-clock seconds and emits the action's generic
"customSmallerIsBetter" schema, so the chart always plots seconds and the benchmarks.js
frontend needs no unit-detection logic.
"""

import argparse
import json


def convert_pytest(data):
results = []
for bench in data["benchmarks"]:
stats = bench["stats"]
results.append(
{
"name": bench["fullname"],
"unit": "seconds",
"value": stats["mean"],
"range": f"stddev: {stats['stddev']}",
"extra": f"rounds: {stats['rounds']}",
}
)
return results


_GOOGLECPP_TIME_UNIT_TO_SECONDS = {
"s": 1.0,
"ms": 1e-3,
"us": 1e-6,
"ns": 1e-9,
}


def convert_googlecpp(data):
results = []
for bench in data["benchmarks"]:
if bench.get("error_occurred"):
continue
factor = _GOOGLECPP_TIME_UNIT_TO_SECONDS[bench["time_unit"]]
results.append(
{
"name": bench["name"],
"unit": "seconds",
"value": bench["real_time"] * factor,
"extra": (
f"iterations: {bench['iterations']}\n"
f"cpu: {bench['cpu_time'] * factor} seconds\n"
f"threads: {bench.get('threads', 1)}"
),
}
)
return results


_CONVERTERS = {
"pytest": convert_pytest,
"googlecpp": convert_googlecpp,
}


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("format", choices=sorted(_CONVERTERS))
parser.add_argument("input", help="Raw benchmark JSON produced by the tool.")
parser.add_argument("output", help="Where to write the customSmallerIsBetter JSON.")
args = parser.parse_args()

with open(args.input) as f:
data = json.load(f)

results = _CONVERTERS[args.format](data)

with open(args.output, "w") as f:
json.dump(results, f, indent=2)


if __name__ == "__main__":
main()
Loading
Loading