From 4f9adfe0cbcba37ad5c0ff8abee346b18fdaf99a Mon Sep 17 00:00:00 2001 From: jalvesz <102541118+jalvesz@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:40:48 +0000 Subject: [PATCH 1/9] Add compile/runtime benchmarks vs Matplotlib and docs section --- benchmarks/complex.f90 | 31 +++++++++++++++++ benchmarks/complex.py | 24 +++++++++++++ benchmarks/requirements.txt | 2 ++ benchmarks/run_benchmarks.py | 66 ++++++++++++++++++++++++++++++++++++ benchmarks/simple.f90 | 21 ++++++++++++ benchmarks/simple.py | 13 +++++++ docs/benchmarks.md | 21 ++++++++++++ docs/index.rst | 3 +- 8 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 benchmarks/complex.f90 create mode 100644 benchmarks/complex.py create mode 100644 benchmarks/requirements.txt create mode 100644 benchmarks/run_benchmarks.py create mode 100644 benchmarks/simple.f90 create mode 100644 benchmarks/simple.py create mode 100644 docs/benchmarks.md diff --git a/benchmarks/complex.f90 b/benchmarks/complex.f90 new file mode 100644 index 0000000..c8b0893 --- /dev/null +++ b/benchmarks/complex.f90 @@ -0,0 +1,31 @@ +program bench_complex + use fplotlib + implicit none + integer, parameter :: n = 5000 + real(dp) :: x(n), y1(n), y2(n), y3(n) + integer :: i + character(len=:), allocatable :: ext + character(len=16) :: arg + + ext = "png" + if (command_argument_count() > 0) then + call get_command_argument(1, arg) + ext = trim(arg) + end if + do i = 1, n + x(i) = 20.0_dp*real(i - 1, dp)/real(n - 1, dp) + y1(i) = sin(x(i))*exp(-0.1_dp*x(i)) + y2(i) = cos(2.0_dp*x(i))*exp(-0.05_dp*x(i)) + y3(i) = 0.5_dp*sin(0.5_dp*x(i)) + end do + call plot(x, y1, "b-", label="damped sin", lw=2.0_dp) + call plot(x, y2, "r--", label="damped cos") + call plot(x, y3, "g-", label="slow sin") + call fill_between(x, y1, y2, alpha=0.2_dp) + call title("Complex benchmark") + call xlabel("time") + call ylabel("amplitude") + call grid(.true.) + call legend() + call savefig("complex_f." // ext) +end program bench_complex diff --git a/benchmarks/complex.py b/benchmarks/complex.py new file mode 100644 index 0000000..f6e2a8b --- /dev/null +++ b/benchmarks/complex.py @@ -0,0 +1,24 @@ +import sys +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +def main(ext="png"): + x = np.linspace(0, 20, 5000) + y1 = np.sin(x)*np.exp(-0.1*x) + y2 = np.cos(2*x)*np.exp(-0.05*x) + y3 = 0.5*np.sin(0.5*x) + plt.plot(x, y1, "b-", label="damped sin", lw=2) + plt.plot(x, y2, "r--", label="damped cos") + plt.plot(x, y3, "g-", label="slow sin") + plt.fill_between(x, y1, y2, alpha=0.2) + plt.title("Complex benchmark") + plt.xlabel("time") + plt.ylabel("amplitude") + plt.grid(True) + plt.legend() + plt.savefig("complex_py." + ext) + +if __name__ == "__main__": + main(*sys.argv[1:2]) diff --git a/benchmarks/requirements.txt b/benchmarks/requirements.txt new file mode 100644 index 0000000..db5d81e --- /dev/null +++ b/benchmarks/requirements.txt @@ -0,0 +1,2 @@ +matplotlib +numpy diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py new file mode 100644 index 0000000..2f6934a --- /dev/null +++ b/benchmarks/run_benchmarks.py @@ -0,0 +1,66 @@ +"""Benchmark fplotlib against Matplotlib (issue #35). + +Measures: library compile time, import time (Python) vs program compile time +(Fortran), and run time of simple/complex plots for PNG and PDF. +Usage: python benchmarks/run_benchmarks.py [--repeats N] [--out results.md] +""" +import argparse, os, shutil, statistics, subprocess, sys, tempfile, time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +FFLAGS = ["-O3", "-march=native"] + + +def timed(cmd, cwd=None): + t = time.perf_counter() + subprocess.run(cmd, cwd=cwd, check=True, stdout=subprocess.DEVNULL) + return time.perf_counter() - t + + +def median(f, n): + return statistics.median(f() for _ in range(n)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--repeats", type=int, default=5) + ap.add_argument("--out", default=None) + a = ap.parse_args() + rows = [] + work = Path(tempfile.mkdtemp()) + build = work / "build" + + # Library compile (full, from scratch) + t = timed(["fpm", "build", "--flag", " ".join(FFLAGS), "--builddir", str(build)], cwd=ROOT) + rows.append(("Compile library", "fplotlib (fpm build, -O3)", t)) + + libdirs = [p for p in build.rglob("libfplotlib.a")] + moddirs = {p.parent for p in build.rglob("fplotlib.mod")} + lib, mod = libdirs[0], next(iter(moddirs)) + + for name in ("simple", "complex"): + exe = work / f"{name}_f" + cmd = ["gfortran", *FFLAGS, "-I", str(mod), str(HERE / f"{name}.f90"), + str(lib), "-o", str(exe)] + t = median(lambda: timed(cmd), a.repeats) + rows.append((f"Compile {name} program", "Fortran compile to binary", t)) + t = median(lambda: timed([sys.executable, "-c", + "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot, numpy"]), a.repeats) + rows.append((f"Import {name} script", "Python imports (matplotlib, numpy)", t)) + for ext in ("png", "pdf"): + tf = median(lambda: timed([str(exe), ext], cwd=work), a.repeats) + tp = median(lambda: timed([sys.executable, str(HERE / f"{name}.py"), ext], cwd=work), a.repeats) + rows.append((f"Run {name} -> {ext}", "fplotlib", tf)) + rows.append((f"Run {name} -> {ext}", "matplotlib (incl. imports)", tp)) + + out = "| Benchmark | Variant | Time (s) |\n|---|---|---|\n" + out += "\n".join(f"| {a_} | {b} | {c:.4f} |" for a_, b, c in rows) + "\n" + print(out) + if a.out: + Path(a.out).write_text(out) + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/simple.f90 b/benchmarks/simple.f90 new file mode 100644 index 0000000..925887d --- /dev/null +++ b/benchmarks/simple.f90 @@ -0,0 +1,21 @@ +program bench_simple + use fplotlib + implicit none + integer, parameter :: n = 200 + real(dp) :: x(n), y(n) + integer :: i + character(len=:), allocatable :: ext + character(len=16) :: arg + + ext = "png" + if (command_argument_count() > 0) then + call get_command_argument(1, arg) + ext = trim(arg) + end if + do i = 1, n + x(i) = 4.0_dp*3.14159265358979_dp*real(i - 1, dp)/real(n - 1, dp) + y(i) = sin(x(i))*exp(-0.1_dp*x(i)) + end do + call plot(x, y, "b-") + call savefig("simple_f." // ext) +end program bench_simple diff --git a/benchmarks/simple.py b/benchmarks/simple.py new file mode 100644 index 0000000..34c67fc --- /dev/null +++ b/benchmarks/simple.py @@ -0,0 +1,13 @@ +import sys +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +def main(ext="png"): + x = np.linspace(0, 4*np.pi, 200) + plt.plot(x, np.sin(x)*np.exp(-0.1*x), "b-") + plt.savefig("simple_py." + ext) + +if __name__ == "__main__": + main(*sys.argv[1:2]) diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..ebb5844 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,21 @@ +# Benchmarks + +fplotlib is compared against Matplotlib (see issue #35). The code lives in +`benchmarks/` and is run with: + +``` +pip install -r benchmarks/requirements.txt +python benchmarks/run_benchmarks.py --repeats 5 --out benchmarks/results.md +``` + +Measured items: + +- **Compilation**: building fplotlib as a library (`fpm build`, `-O3`). +- **Import vs. compile**: Python imports (`matplotlib`, `numpy`) versus + compiling the Fortran program to a binary. +- **Runtime**: generating a PNG and a PDF from a *simple* plot (one line) and a + *complex* plot (3 curves, fill, legend, grid, labels, 5000 points), Python + script versus Fortran binary. + +Results (machine dependent) are written to `benchmarks/results.md`; paste them +here once measured on reference hardware. diff --git a/docs/index.rst b/docs/index.rst index 767b001..f0f9312 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,4 +9,5 @@ fplotlib :caption: Contents: api/index - auto_gallery/index \ No newline at end of file + auto_gallery/index + benchmarks From b009e7b772b8744cb64bbb010fa9fd28874fded6 Mon Sep 17 00:00:00 2001 From: jalvesz <102541118+jalvesz@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:45:16 +0000 Subject: [PATCH 2/9] Run benchmarks in docs CI and include results in the docs --- .github/workflows/docs.yml | 8 ++++++++ docs/benchmarks.md | 9 +++++++-- docs/benchmarks_results.txt | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 docs/benchmarks_results.txt diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 26c7b53..cfcd7c8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,6 +28,7 @@ on: # reference reads the sources. - 'src/**' - 'examples/**' + - 'benchmarks/**' - 'fpm.toml' pull_request: paths: *paths @@ -107,6 +108,13 @@ jobs: sphinx-build --version ford --version + # Writes the results table that docs/benchmarks.md includes. Timings + # come from the CI runner, so they are only comparable within one run. + - name: Run benchmarks + run: | + pip install -r benchmarks/requirements.txt + python benchmarks/run_benchmarks.py --repeats 5 --out docs/benchmarks_results.txt + - name: Build Docs env: # Picked up by docs/conf.py as fplotlib_gallery_compiler. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index ebb5844..646e3fc 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -17,5 +17,10 @@ Measured items: *complex* plot (3 curves, fill, legend, grid, labels, 5000 points), Python script versus Fortran binary. -Results (machine dependent) are written to `benchmarks/results.md`; paste them -here once measured on reference hardware. +## Results + +Generated by the docs CI workflow on a GitHub runner at build time. Timings +are medians and only comparable within a single run. + +```{include} benchmarks_results.txt +``` diff --git a/docs/benchmarks_results.txt b/docs/benchmarks_results.txt new file mode 100644 index 0000000..5fb086f --- /dev/null +++ b/docs/benchmarks_results.txt @@ -0,0 +1 @@ +*Results are generated by the docs CI workflow.* From d03b2cf023fa074eb4648cdb0e1a050750fa3522 Mon Sep 17 00:00:00 2001 From: jalvesz <102541118+jalvesz@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:49:59 +0000 Subject: [PATCH 3/9] Benchmarks: use fpm default build dir --- benchmarks/run_benchmarks.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index 2f6934a..3bcbf99 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -29,14 +29,17 @@ def main(): a = ap.parse_args() rows = [] work = Path(tempfile.mkdtemp()) - build = work / "build" + build = ROOT / "build" + shutil.rmtree(build, ignore_errors=True) # Library compile (full, from scratch) - t = timed(["fpm", "build", "--flag", " ".join(FFLAGS), "--builddir", str(build)], cwd=ROOT) + t = timed(["fpm", "build", "--flag", " ".join(FFLAGS)], cwd=ROOT) rows.append(("Compile library", "fplotlib (fpm build, -O3)", t)) libdirs = [p for p in build.rglob("libfplotlib.a")] moddirs = {p.parent for p in build.rglob("fplotlib.mod")} + if not libdirs or not moddirs: + sys.exit(f"fpm build produced no library under {build}") lib, mod = libdirs[0], next(iter(moddirs)) for name in ("simple", "complex"): From a4b8fec02167ed7ff28dcad7576e2db269b089ec Mon Sep 17 00:00:00 2001 From: jalvesz <102541118+jalvesz@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:54:42 +0000 Subject: [PATCH 4/9] Benchmarks: time Python run separately from imports, verify outputs --- benchmarks/complex.py | 3 +++ benchmarks/run_benchmarks.py | 21 +++++++++++++++++++-- benchmarks/simple.py | 3 +++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/benchmarks/complex.py b/benchmarks/complex.py index f6e2a8b..4c718b9 100644 --- a/benchmarks/complex.py +++ b/benchmarks/complex.py @@ -1,4 +1,5 @@ import sys +import time import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt @@ -21,4 +22,6 @@ def main(ext="png"): plt.savefig("complex_py." + ext) if __name__ == "__main__": + t0 = time.perf_counter() main(*sys.argv[1:2]) + print(f"RUN_SECONDS {time.perf_counter() - t0:.6f}") diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index 3bcbf99..ad23191 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -18,6 +18,21 @@ def timed(cmd, cwd=None): return time.perf_counter() - t +def py_run(script, ext, cwd): + """Run time of main() alone, as reported by the script after its imports.""" + r = subprocess.run([sys.executable, str(script), ext], cwd=cwd, check=True, + capture_output=True, text=True) + for line in r.stdout.splitlines(): + if line.startswith("RUN_SECONDS"): + return float(line.split()[1]) + raise RuntimeError(f"no RUN_SECONDS in output of {script}") + + +def check(path): + if not path.is_file() or path.stat().st_size == 0: + sys.exit(f"missing or empty output: {path}") + + def median(f, n): return statistics.median(f() for _ in range(n)) @@ -53,9 +68,11 @@ def main(): rows.append((f"Import {name} script", "Python imports (matplotlib, numpy)", t)) for ext in ("png", "pdf"): tf = median(lambda: timed([str(exe), ext], cwd=work), a.repeats) - tp = median(lambda: timed([sys.executable, str(HERE / f"{name}.py"), ext], cwd=work), a.repeats) + tp = median(lambda: py_run(HERE / f"{name}.py", ext, work), a.repeats) + check(work / f"{name}_f.{ext}") + check(work / f"{name}_py.{ext}") rows.append((f"Run {name} -> {ext}", "fplotlib", tf)) - rows.append((f"Run {name} -> {ext}", "matplotlib (incl. imports)", tp)) + rows.append((f"Run {name} -> {ext}", "matplotlib (excl. imports)", tp)) out = "| Benchmark | Variant | Time (s) |\n|---|---|---|\n" out += "\n".join(f"| {a_} | {b} | {c:.4f} |" for a_, b, c in rows) + "\n" diff --git a/benchmarks/simple.py b/benchmarks/simple.py index 34c67fc..3a5c19b 100644 --- a/benchmarks/simple.py +++ b/benchmarks/simple.py @@ -1,4 +1,5 @@ import sys +import time import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt @@ -10,4 +11,6 @@ def main(ext="png"): plt.savefig("simple_py." + ext) if __name__ == "__main__": + t0 = time.perf_counter() main(*sys.argv[1:2]) + print(f"RUN_SECONDS {time.perf_counter() - t0:.6f}") From c0b46f407d7298003a22a7df7c03febfa74564a7 Mon Sep 17 00:00:00 2001 From: jalvesz <102541118+jalvesz@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:17:54 +0000 Subject: [PATCH 5/9] Benchmarks: add GIF animation benchmark --- benchmarks/animation.f90 | 29 +++++++++++++++++++++++++++++ benchmarks/animation.py | 36 ++++++++++++++++++++++++++++++++++++ benchmarks/run_benchmarks.py | 9 +++++---- docs/benchmarks.md | 3 +++ 4 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 benchmarks/animation.f90 create mode 100644 benchmarks/animation.py diff --git a/benchmarks/animation.f90 b/benchmarks/animation.f90 new file mode 100644 index 0000000..c4957d7 --- /dev/null +++ b/benchmarks/animation.f90 @@ -0,0 +1,29 @@ +program bench_animation + use fplotlib + implicit none + integer, parameter :: n = 240, n_frames = 40 + real(dp), parameter :: pi = 3.14159265358979323846_dp + real(dp) :: x(n), y(n), envelope(n), phase + integer :: frame, i + + do i = 1, n + x(i) = 10.0_dp*real(i - 1, dp)/real(n - 1, dp) + end do + envelope = exp(-0.15_dp*x) + + do frame = 1, n_frames + phase = 2.0_dp*pi*real(frame - 1, dp)/real(n_frames, dp) + y = sin(2.0_dp*x - phase)*envelope + call clf() + call plot(x, y, "b-", lw=2.0_dp) + call fill_between(x, y, color="b", alpha=0.2_dp) + call xlim(0.0_dp, 10.0_dp) + call ylim(-1.1_dp, 1.1_dp) + call title("Travelling wave") + call xlabel("x") + call ylabel("displacement") + call grid(.true.) + call add_frame() + end do + call save_animation("animation_f.gif", fps=20.0_dp) +end program bench_animation diff --git a/benchmarks/animation.py b/benchmarks/animation.py new file mode 100644 index 0000000..f3ff5a9 --- /dev/null +++ b/benchmarks/animation.py @@ -0,0 +1,36 @@ +import sys +import time +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.animation import FuncAnimation, PillowWriter + + +def main(ext="gif"): + n_frames = 40 + x = np.linspace(0, 10, 240) + envelope = np.exp(-0.15*x) + fig, ax = plt.subplots() + + # Every frame is cleared and redrawn, like clf() + plot() in the Fortran program. + def draw(frame): + ax.clear() + y = np.sin(2*x - 2*np.pi*frame/n_frames)*envelope + ax.plot(x, y, "b-", lw=2) + ax.fill_between(x, y, color="b", alpha=0.2) + ax.set_xlim(0, 10) + ax.set_ylim(-1.1, 1.1) + ax.set_title("Travelling wave") + ax.set_xlabel("x") + ax.set_ylabel("displacement") + ax.grid(True) + + anim = FuncAnimation(fig, draw, frames=n_frames) + anim.save("animation_py." + ext, writer=PillowWriter(fps=20)) + + +if __name__ == "__main__": + t0 = time.perf_counter() + main(*sys.argv[1:2]) + print(f"RUN_SECONDS {time.perf_counter() - t0:.6f}") diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index ad23191..2978688 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -1,7 +1,7 @@ """Benchmark fplotlib against Matplotlib (issue #35). Measures: library compile time, import time (Python) vs program compile time -(Fortran), and run time of simple/complex plots for PNG and PDF. +(Fortran), and run time of simple/complex plots for PNG and PDF and a GIF animation. Usage: python benchmarks/run_benchmarks.py [--repeats N] [--out results.md] """ import argparse, os, shutil, statistics, subprocess, sys, tempfile, time @@ -57,16 +57,17 @@ def main(): sys.exit(f"fpm build produced no library under {build}") lib, mod = libdirs[0], next(iter(moddirs)) - for name in ("simple", "complex"): + for name, exts in (("simple", ("png", "pdf")), ("complex", ("png", "pdf")), + ("animation", ("gif",))): exe = work / f"{name}_f" cmd = ["gfortran", *FFLAGS, "-I", str(mod), str(HERE / f"{name}.f90"), str(lib), "-o", str(exe)] t = median(lambda: timed(cmd), a.repeats) rows.append((f"Compile {name} program", "Fortran compile to binary", t)) t = median(lambda: timed([sys.executable, "-c", - "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot, numpy"]), a.repeats) + "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot, matplotlib.animation, numpy"]), a.repeats) rows.append((f"Import {name} script", "Python imports (matplotlib, numpy)", t)) - for ext in ("png", "pdf"): + for ext in exts: tf = median(lambda: timed([str(exe), ext], cwd=work), a.repeats) tp = median(lambda: py_run(HERE / f"{name}.py", ext, work), a.repeats) check(work / f"{name}_f.{ext}") diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 646e3fc..ed808af 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -16,6 +16,9 @@ Measured items: - **Runtime**: generating a PNG and a PDF from a *simple* plot (one line) and a *complex* plot (3 curves, fill, legend, grid, labels, 5000 points), Python script versus Fortran binary. +- **Animation**: a 40-frame, 240-point travelling wave saved as a GIF + (`clf`/`plot`/`add_frame`/`save_animation` versus Matplotlib + `FuncAnimation` with `PillowWriter`). ## Results From 910d4af7fd7b224cf59dd1d0499443f5ad9f0acc Mon Sep 17 00:00:00 2001 From: jalvesz <102541118+jalvesz@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:50:34 +0000 Subject: [PATCH 6/9] Benchmarks: store per-machine JSON results and render them as collapsible docs tables --- .github/workflows/docs.yml | 6 ++-- .gitignore | 4 +++ benchmarks/render_results.py | 54 +++++++++++++++++++++++++++++ benchmarks/results/README.md | 18 ++++++++++ benchmarks/run_benchmarks.py | 67 +++++++++++++++++++++++++++++++----- docs/benchmarks.md | 12 ++++--- docs/benchmarks_results.txt | 1 - docs/conf.py | 11 +++++- 8 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 benchmarks/render_results.py create mode 100644 benchmarks/results/README.md delete mode 100644 docs/benchmarks_results.txt diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cfcd7c8..8b808d1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -108,12 +108,12 @@ jobs: sphinx-build --version ford --version - # Writes the results table that docs/benchmarks.md includes. Timings - # come from the CI runner, so they are only comparable within one run. + # Adds benchmarks/results/github-actions.json, which docs/conf.py renders + # on the benchmarks page. Not committed; timings from the CI runner. - name: Run benchmarks run: | pip install -r benchmarks/requirements.txt - python benchmarks/run_benchmarks.py --repeats 5 --out docs/benchmarks_results.txt + python benchmarks/run_benchmarks.py --repeats 5 --save github-actions - name: Build Docs env: diff --git a/.gitignore b/.gitignore index ece3242..a569f86 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ __pycache__/ docs/_build/ docs/auto_gallery/ docs/sg_execution_times.rst + +# generated docs content +docs/_generated/ +benchmarks/results/github-actions.json diff --git a/benchmarks/render_results.py b/benchmarks/render_results.py new file mode 100644 index 0000000..f9ca169 --- /dev/null +++ b/benchmarks/render_results.py @@ -0,0 +1,54 @@ +"""Turn benchmarks/results/*.json into a Markdown page with collapsible tables. + +Used by docs/conf.py, so the docs always show whatever is in the results +folder. Usage: python benchmarks/render_results.py OUTPUT.md +""" +import html +import json +import sys +from pathlib import Path + +RESULTS = Path(__file__).resolve().parent / "results" +MACHINE_LABELS = [ + ("cpu", "CPU"), ("logical_cores", "Logical cores"), ("os", "OS"), + ("fortran_compiler", "Fortran compiler"), ("fortran_flags", "Fortran flags"), + ("fpm", "fpm"), ("python", "Python"), ("matplotlib", "Matplotlib"), + ("numpy", "NumPy"), +] + + +def e(x): + return html.escape(str(x)) + + +def section(path, data): + m = data["machine"] + out = [f"
{e(m.get('cpu', path.stem))} — " + f"{e(m.get('os', ''))} — {e(data.get('date', ''))} " + f"({e(path.name)})", "

Machine

", ""] + for key, label in MACHINE_LABELS: + if key in m: + out.append(f"") + out.append("
{label}{e(m[key])}
") + out.append(f"

Results (median of {e(data.get('repeats', '?'))} runs)

") + out.append("" + "") + for r in data["results"]: + out.append(f"" + f"") + out.append("
BenchmarkVariantTime (s)
{e(r['benchmark'])}{e(r['variant'])}{r['seconds']:.4f}
") + return "\n".join(out) + + +def render(): + blocks = [] + for path in sorted(RESULTS.glob("*.json")): + blocks.append("```{raw} html\n" + section(path, json.loads(path.read_text())) + + "\n```\n") + if not blocks: + return "*No results have been recorded yet.*\n" + return "\n".join(blocks) + + +if __name__ == "__main__": + Path(sys.argv[1]).write_text(render()) diff --git a/benchmarks/results/README.md b/benchmarks/results/README.md new file mode 100644 index 0000000..8e4b146 --- /dev/null +++ b/benchmarks/results/README.md @@ -0,0 +1,18 @@ +# Benchmark results + +One JSON file per machine and run. Each records the CPU, OS, Fortran compiler +and flags, Python, Matplotlib and NumPy versions, and the timings. + +To contribute a result: + +``` +pip install -r benchmarks/requirements.txt +python benchmarks/run_benchmarks.py --repeats 5 --save +``` + +This needs `fpm` and `gfortran` on the PATH. It writes +`benchmarks/results/-.json`; commit that file and open a pull +request. The docs list every file here as a collapsible section. + +`github-actions.json` is produced by the docs workflow at build time and is +not committed. diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index 2978688..2521ffd 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -2,9 +2,12 @@ Measures: library compile time, import time (Python) vs program compile time (Fortran), and run time of simple/complex plots for PNG and PDF and a GIF animation. -Usage: python benchmarks/run_benchmarks.py [--repeats N] [--out results.md] +Usage: python benchmarks/run_benchmarks.py [--repeats N] [--save [NAME]] + +--save writes benchmarks/results/NAME.json with the machine description and +the timings, ready to be proposed in a pull request. """ -import argparse, os, shutil, statistics, subprocess, sys, tempfile, time +import argparse, datetime, json, os, platform, re, shutil, statistics, subprocess, sys, tempfile, time from pathlib import Path HERE = Path(__file__).resolve().parent @@ -33,6 +36,41 @@ def check(path): sys.exit(f"missing or empty output: {path}") +def first_line(cmd): + try: + r = subprocess.run(cmd, capture_output=True, text=True, check=True) + return (r.stdout or r.stderr).splitlines()[0].strip() + except (OSError, subprocess.CalledProcessError, IndexError): + return "unknown" + + +def cpu_name(): + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + if platform.system() == "Darwin": + return first_line(["sysctl", "-n", "machdep.cpu.brand_string"]) + return platform.processor() or platform.machine() + + +def machine_info(): + import matplotlib, numpy + return { + "cpu": cpu_name(), + "logical_cores": os.cpu_count(), + "os": platform.platform(), + "fortran_compiler": first_line(["gfortran", "--version"]), + "fortran_flags": " ".join(FFLAGS), + "fpm": first_line(["fpm", "--version"]), + "python": f"{platform.python_implementation()} {platform.python_version()}", + "matplotlib": matplotlib.__version__, + "numpy": numpy.__version__, + } + + def median(f, n): return statistics.median(f() for _ in range(n)) @@ -40,7 +78,9 @@ def median(f, n): def main(): ap = argparse.ArgumentParser() ap.add_argument("--repeats", type=int, default=5) - ap.add_argument("--out", default=None) + ap.add_argument("--save", nargs="?", const="", metavar="NAME", default=None, + help="write benchmarks/results/NAME.json (default name: " + "date and CPU)") a = ap.parse_args() rows = [] work = Path(tempfile.mkdtemp()) @@ -75,11 +115,22 @@ def main(): rows.append((f"Run {name} -> {ext}", "fplotlib", tf)) rows.append((f"Run {name} -> {ext}", "matplotlib (excl. imports)", tp)) - out = "| Benchmark | Variant | Time (s) |\n|---|---|---|\n" - out += "\n".join(f"| {a_} | {b} | {c:.4f} |" for a_, b, c in rows) + "\n" - print(out) - if a.out: - Path(a.out).write_text(out) + print(f"{'Benchmark':32} {'Variant':36} Time (s)") + for b, v, t in rows: + print(f"{b:32} {v:36} {t:.4f}") + if a.save is not None: + info = machine_info() + name = a.save or "{}-{}".format( + datetime.date.today().isoformat(), + re.sub(r"[^a-z0-9]+", "-", info["cpu"].lower()).strip("-")) + data = {"date": datetime.date.today().isoformat(), "repeats": a.repeats, + "machine": info, + "results": [{"benchmark": b, "variant": v, "seconds": round(t, 6)} + for b, v, t in rows]} + dest = HERE / "results" / f"{name}.json" + dest.parent.mkdir(exist_ok=True) + dest.write_text(json.dumps(data, indent=2) + "\n") + print(f"\nSaved {dest}") shutil.rmtree(work, ignore_errors=True) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index ed808af..6541b58 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -5,7 +5,7 @@ fplotlib is compared against Matplotlib (see issue #35). The code lives in ``` pip install -r benchmarks/requirements.txt -python benchmarks/run_benchmarks.py --repeats 5 --out benchmarks/results.md +python benchmarks/run_benchmarks.py --repeats 5 --save ``` Measured items: @@ -22,8 +22,12 @@ Measured items: ## Results -Generated by the docs CI workflow on a GitHub runner at build time. Timings -are medians and only comparable within a single run. +Each entry below is one run on one machine; click it to open or close the +table. Timings are medians and are only comparable within a single entry. -```{include} benchmarks_results.txt +To add your machine, run the command above and propose the new +`benchmarks/results/*.json` file in a pull request. The `github-actions` entry +is produced by the docs workflow on a GitHub runner at build time. + +```{include} _generated/benchmarks_results.md ``` diff --git a/docs/benchmarks_results.txt b/docs/benchmarks_results.txt deleted file mode 100644 index 5fb086f..0000000 --- a/docs/benchmarks_results.txt +++ /dev/null @@ -1 +0,0 @@ -*Results are generated by the docs CI workflow.* diff --git a/docs/conf.py b/docs/conf.py index a5fd8f3..a4db3a2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -38,7 +38,7 @@ # 'gallery' holds the gallery *sources*; Sphinx-Gallery copies them into # 'auto_gallery', so leaving them visible here would give every label and # document in them a duplicate. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'gallery'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'gallery', '_generated'] # Select a lexer (built-in: "regex") fortran_lexer = "ford" @@ -95,3 +95,12 @@ # If true, the current module name will be prepended to all description # unit titles (such as .. function::). add_module_names = False + +# -- Benchmarks -------------------------------------------------------------- +# docs/benchmarks.md includes a page rendered from benchmarks/results/*.json. +sys.path.insert(0, os.path.abspath('../benchmarks')) +import render_results # noqa: E402 + +os.makedirs('_generated', exist_ok=True) +with open(os.path.join('_generated', 'benchmarks_results.md'), 'w') as f: + f.write(render_results.render()) From a865e0a61f48b1a6e2e6669ed390829bdcc377dc Mon Sep 17 00:00:00 2001 From: jalvesz <102541118+jalvesz@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:31:33 +0000 Subject: [PATCH 7/9] Benchmarks: drop core count, simplify OS, record CPU clock speed --- benchmarks/render_results.py | 4 ++-- benchmarks/results/README.md | 2 +- benchmarks/run_benchmarks.py | 40 ++++++++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/benchmarks/render_results.py b/benchmarks/render_results.py index f9ca169..47a9fe0 100644 --- a/benchmarks/render_results.py +++ b/benchmarks/render_results.py @@ -10,7 +10,7 @@ RESULTS = Path(__file__).resolve().parent / "results" MACHINE_LABELS = [ - ("cpu", "CPU"), ("logical_cores", "Logical cores"), ("os", "OS"), + ("cpu", "CPU"), ("cpu_ghz", "Clock speed (GHz)"), ("os", "OS"), ("fortran_compiler", "Fortran compiler"), ("fortran_flags", "Fortran flags"), ("fpm", "fpm"), ("python", "Python"), ("matplotlib", "Matplotlib"), ("numpy", "NumPy"), @@ -27,7 +27,7 @@ def section(path, data): f"{e(m.get('os', ''))} — {e(data.get('date', ''))} " f"({e(path.name)})", "

Machine

", ""] for key, label in MACHINE_LABELS: - if key in m: + if m.get(key) is not None: out.append(f"") out.append("
{label}{e(m[key])}
") out.append(f"

Results (median of {e(data.get('repeats', '?'))} runs)

") diff --git a/benchmarks/results/README.md b/benchmarks/results/README.md index 8e4b146..41db1ab 100644 --- a/benchmarks/results/README.md +++ b/benchmarks/results/README.md @@ -1,6 +1,6 @@ # Benchmark results -One JSON file per machine and run. Each records the CPU, OS, Fortran compiler +One JSON file per machine and run. Each records the CPU and its clock speed, the OS (Windows, Linux or macOS), Fortran compiler and flags, Python, Matplotlib and NumPy versions, and the timings. To contribute a result: diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index 2521ffd..fd61c42 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -56,12 +56,48 @@ def cpu_name(): return platform.processor() or platform.machine() +def cpu_ghz(): + """Maximum clock speed in GHz when the OS reports it, else None.""" + mhz = None + system = platform.system() + try: + if system == "Linux": + for line in first_text(["lscpu"]).splitlines(): + if line.strip().startswith("CPU max MHz"): + mhz = float(line.split(":", 1)[1]) + if mhz is None: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.startswith("cpu MHz"): + mhz = float(line.split(":", 1)[1]) + break + elif system == "Darwin": + mhz = float(first_line(["sysctl", "-n", "hw.cpufrequency_max"])) / 1e6 + elif system == "Windows": + out = first_text(["powershell", "-NoProfile", "-Command", + "(Get-CimInstance Win32_Processor).MaxClockSpeed"]) + mhz = float(out.split()[0]) + except (OSError, ValueError, IndexError): + pass + return round(mhz / 1000, 2) if mhz else None + + +def first_text(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout + except (OSError, subprocess.CalledProcessError): + return "" + + +def os_name(): + return {"Darwin": "macOS"}.get(platform.system(), platform.system()) + + def machine_info(): import matplotlib, numpy return { "cpu": cpu_name(), - "logical_cores": os.cpu_count(), - "os": platform.platform(), + "cpu_ghz": cpu_ghz(), + "os": os_name(), "fortran_compiler": first_line(["gfortran", "--version"]), "fortran_flags": " ".join(FFLAGS), "fpm": first_line(["fpm", "--version"]), From 33a51f6f04fae3af90385d4842e2085e3a9f50c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Alves?= <102541118+jalvesz@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:54:05 +0100 Subject: [PATCH 8/9] remove redundant info --- docs/benchmarks.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 6541b58..0d723d5 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,7 +1,6 @@ # Benchmarks -fplotlib is compared against Matplotlib (see issue #35). The code lives in -`benchmarks/` and is run with: +fplotlib is compared against Matplotlib. The code lives in `benchmarks/` and is run with: ``` pip install -r benchmarks/requirements.txt From 94b2cffafcc652aab7f67f270c5d5b02c886cac0 Mon Sep 17 00:00:00 2001 From: Jose Alves Date: Sat, 7 Nov 2026 22:48:58 +0100 Subject: [PATCH 9/9] benchmark results --- benchmarks/render_results.py | 21 +++- ...-intel-r-core-tm-i7-8565u-cpu-1-80ghz.json | 102 ++++++++++++++++++ docs/benchmarks.md | 1 + 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 benchmarks/results/2026-11-07-intel-r-core-tm-i7-8565u-cpu-1-80ghz.json diff --git a/benchmarks/render_results.py b/benchmarks/render_results.py index 47a9fe0..9e3347c 100644 --- a/benchmarks/render_results.py +++ b/benchmarks/render_results.py @@ -31,11 +31,24 @@ def section(path, data): out.append(f"{label}{e(m[key])}") out.append("") out.append(f"

Results (median of {e(data.get('repeats', '?'))} runs)

") - out.append("" - "") + out.append("
BenchmarkVariantTime (s)
" + "") + results = {} for r in data["results"]: - out.append(f"" - f"") + variant = r["variant"] + if variant.startswith(("fplotlib", "Fortran compile")): + column = "fplotlib" + elif variant.startswith(("matplotlib", "Python imports")): + column = "matplotlib" + else: + raise ValueError(f"Unknown benchmark variant: {variant}") + timings = results.setdefault(r["benchmark"], {}) + if column in timings: + raise ValueError(f"Duplicate {column} result for {r['benchmark']}") + timings[column] = f"{r['seconds']:.4f}" + for benchmark, timings in results.items(): + out.append(f"" + f"") out.append("
Benchmarkfplotlib (s)matplotlib (s)
{e(r['benchmark'])}{e(r['variant'])}{r['seconds']:.4f}
{e(benchmark)}{timings.get('fplotlib', '')}{timings.get('matplotlib', '')}
") return "\n".join(out) diff --git a/benchmarks/results/2026-11-07-intel-r-core-tm-i7-8565u-cpu-1-80ghz.json b/benchmarks/results/2026-11-07-intel-r-core-tm-i7-8565u-cpu-1-80ghz.json new file mode 100644 index 0000000..5019010 --- /dev/null +++ b/benchmarks/results/2026-11-07-intel-r-core-tm-i7-8565u-cpu-1-80ghz.json @@ -0,0 +1,102 @@ +{ + "date": "2026-11-07", + "repeats": 5, + "machine": { + "cpu": "Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz", + "cpu_ghz": 0.4, + "os": "Linux", + "fortran_compiler": "GNU Fortran (Ubuntu 15.2.0-16ubuntu1) 15.2.0", + "fortran_flags": "-O3 -march=native", + "fpm": "Version: 0.13.0, alpha", + "python": "CPython 3.14.4", + "matplotlib": "3.11.2", + "numpy": "2.3.5" + }, + "results": [ + { + "benchmark": "Compile library", + "variant": "fplotlib (fpm build, -O3)", + "seconds": 21.074636 + }, + { + "benchmark": "Compile simple program", + "variant": "Fortran compile to binary", + "seconds": 0.110779 + }, + { + "benchmark": "Import simple script", + "variant": "Python imports (matplotlib, numpy)", + "seconds": 0.43922 + }, + { + "benchmark": "Run simple -> png", + "variant": "fplotlib", + "seconds": 0.048328 + }, + { + "benchmark": "Run simple -> png", + "variant": "matplotlib (excl. imports)", + "seconds": 0.074703 + }, + { + "benchmark": "Run simple -> pdf", + "variant": "fplotlib", + "seconds": 0.002185 + }, + { + "benchmark": "Run simple -> pdf", + "variant": "matplotlib (excl. imports)", + "seconds": 0.133638 + }, + { + "benchmark": "Compile complex program", + "variant": "Fortran compile to binary", + "seconds": 0.11186 + }, + { + "benchmark": "Import complex script", + "variant": "Python imports (matplotlib, numpy)", + "seconds": 0.425692 + }, + { + "benchmark": "Run complex -> png", + "variant": "fplotlib", + "seconds": 0.069669 + }, + { + "benchmark": "Run complex -> png", + "variant": "matplotlib (excl. imports)", + "seconds": 0.152285 + }, + { + "benchmark": "Run complex -> pdf", + "variant": "fplotlib", + "seconds": 0.056001 + }, + { + "benchmark": "Run complex -> pdf", + "variant": "matplotlib (excl. imports)", + "seconds": 0.226113 + }, + { + "benchmark": "Compile animation program", + "variant": "Fortran compile to binary", + "seconds": 0.090391 + }, + { + "benchmark": "Import animation script", + "variant": "Python imports (matplotlib, numpy)", + "seconds": 0.43272 + }, + { + "benchmark": "Run animation -> gif", + "variant": "fplotlib", + "seconds": 0.966446 + }, + { + "benchmark": "Run animation -> gif", + "variant": "matplotlib (excl. imports)", + "seconds": 4.471388 + } + ] +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 0d723d5..f775e4f 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -23,6 +23,7 @@ Measured items: Each entry below is one run on one machine; click it to open or close the table. Timings are medians and are only comparable within a single entry. +Matplotlib run times exclude imports, which are measured separately (look for the "Import" lines). To add your machine, run the command above and propose the new `benchmarks/results/*.json` file in a pull request. The `github-actions` entry