From d7dd9b2d0fbd9e4b9d4615fc011f86c04fcc4740 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 15 Dec 2025 14:53:59 -0800 Subject: [PATCH 1/6] Added: Benchmarks and plotting for performance analysis Adds a comprehensive benchmark suite comparing `la-stack` against `nalgebra` for various linear algebra operations across different dimensions. Includes tooling to automatically generate plots and update the README with benchmark results. This facilitates ongoing performance monitoring and optimization. --- .codacy.yml | 43 +- .github/workflows/ci.yml | 6 + .github/workflows/codacy.yml | 13 + .gitignore | 10 + .python-version | 1 + Cargo.toml | 1 + README.md | 26 ++ benches/vs_nalgebra.rs | 282 ++++++++++-- cspell.json | 25 +- docs/assets/bench/.gitkeep | 1 + .../bench/vs_nalgebra_lu_solve_median.csv | 9 + .../bench/vs_nalgebra_lu_solve_median.svg | 377 +++++++++++++++ justfile | 66 ++- pyproject.toml | 108 +++++ scripts/README.md | 120 +++++ scripts/criterion_dim_plot.py | 428 ++++++++++++++++++ scripts/tests/__init__.py | 1 + scripts/tests/test_criterion_dim_plot.py | 174 +++++++ src/lu.rs | 2 - src/matrix.rs | 8 - src/vector.rs | 6 - 21 files changed, 1658 insertions(+), 49 deletions(-) create mode 100644 .python-version create mode 100644 docs/assets/bench/.gitkeep create mode 100644 docs/assets/bench/vs_nalgebra_lu_solve_median.csv create mode 100644 docs/assets/bench/vs_nalgebra_lu_solve_median.svg create mode 100644 pyproject.toml create mode 100644 scripts/README.md create mode 100644 scripts/criterion_dim_plot.py create mode 100644 scripts/tests/__init__.py create mode 100644 scripts/tests/test_criterion_dim_plot.py diff --git a/.codacy.yml b/.codacy.yml index 82d90dc..a3fde82 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -3,6 +3,7 @@ # # This repository is primarily Rust. # Note: clippy and rustfmt are not supported by Codacy and are handled by GitHub Actions CI. +# cspell:ignore pyproject engines: # === DOCUMENTATION / SCRIPTS === @@ -22,6 +23,29 @@ engines: severity: warning include_code: true + # === PYTHON / SECURITY === + # Ruff for Python linting and formatting (reads from pyproject.toml) + ruff: + enabled: true + include_paths: + - "scripts/**/*.py" + - "**/*.py" + config: + file: "pyproject.toml" + + # Bandit for Python security analysis + bandit: + enabled: true + include_paths: + - "scripts/**/*.py" + - "**/*.py" + config: + severity: high + confidence: high + skips: ["B101", "B102", "B103", "B108", "B110", "B404", "B603", "B607"] + exclude_info: true + exclude_dirs: ["tests"] + # === RUST / SECURITY === lizard: enabled: true @@ -30,8 +54,9 @@ engines: - "tests/**/*.rs" - "examples/**/*.rs" - "benches/**/*.rs" + - "scripts/**/*.py" config: - languages: ["rust"] + languages: ["rust", "python"] threshold: cyclomatic_complexity: 15 token_count: 300 @@ -46,6 +71,7 @@ engines: - "tests/**/*.rs" - "examples/**/*.rs" - "benches/**/*.rs" + - "scripts/**/*.py" trivy: enabled: true @@ -75,6 +101,15 @@ exclude_paths: - ".git/**" - ".cspellcache" - ".DS_Store" + # Python artifacts + - "__pycache__/**" + - "*.pyc" + - ".pytest_cache/**" + - ".ruff_cache/**" + - ".mypy_cache/**" + - "venv/**" + - ".venv/**" + - "uv.lock" # Focus analysis on source, docs, and CI configuration include_paths: @@ -82,7 +117,10 @@ include_paths: - "benches/**" - "examples/**" - "tests/**" + - "scripts/**" + - "*.py" - "Cargo.toml" + - "pyproject.toml" - "rust-toolchain.toml" - "rustfmt.toml" - "justfile" @@ -100,6 +138,9 @@ languages: rust: extensions: - ".rs" + python: + extensions: + - ".py" markdown: extensions: - ".md" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67776f2..5e22976 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,12 @@ jobs: with: tool: just + - name: Install uv (for Python scripts and pytest) + if: matrix.os != 'windows-latest' + uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 + with: + version: "latest" + - name: Install Node.js (for markdownlint and cspell) if: matrix.os != 'windows-latest' uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml index 0a6f41b..d131841 100644 --- a/.github/workflows/codacy.yml +++ b/.github/workflows/codacy.yml @@ -61,6 +61,19 @@ jobs: mkdir -p "$CODACY_WORKDIR" rsync -a --delete --exclude '.git' ./ "$CODACY_WORKDIR/" + - name: Verify Codacy config includes Python security tooling + run: | + set -euo pipefail + config="$CODACY_WORKDIR/.codacy.yml" + if [ ! -f "$config" ]; then + echo "::error::.codacy.yml not found in workspace copy ($config)" + exit 1 + fi + if ! grep -qE '^ bandit:' "$config"; then + echo "::error::Bandit engine not configured in .codacy.yml; Python security scanning will be skipped." + exit 1 + fi + # Execute Codacy Analysis CLI and generate a SARIF output with # the security issues identified during the analysis - name: Run Codacy Analysis CLI diff --git a/.gitignore b/.gitignore index fdf592a..5ab517c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,13 @@ /build* /cobertura.xml .DS_Store + +# Python / uv +__pycache__/ +**/__pycache__/ +*.egg-info/ +.venv/ +.ruff_cache/ +.pytest_cache/ +.mypy_cache/ +uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Cargo.toml b/Cargo.toml index 6daaf94..fbb54c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ rust-version = "1.92" license = "BSD-3-Clause" description = "Small, stack-allocated linear algebra for fixed dimensions" readme = "README.md" +documentation = "https://docs.rs/la-stack" repository = "https://github.com/acgetchell/la-stack" categories = ["mathematics", "science"] keywords = ["linear-algebra", "geometry", "const-generics"] diff --git a/README.md b/README.md index bf9f796..cb26335 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ while keeping the API intentionally small and explicit. - ✅ `unsafe` forbidden - ✅ No runtime dependencies (dev-dependencies are for contributors only) +## 🚫 Anti-goals + +- Comprehensive: use [`nalgebra`](https://crates.io/crates/nalgebra) if you need a full-featured library +- Bare-metal performance: see [`blas-src`](https://crates.io/crates/blas-src), [`lapack-src`](https://crates.io/crates/lapack-src), [`openblas-src`](https://crates.io/crates/openblas-src) + ## 🔢 Scalar types Today, the core types are implemented for `f64`. The intent is to support `f32` and `f64` @@ -107,6 +112,27 @@ just commit-check # lint + all tests + examples For the full set of developer commands, see `just --list` and `WARP.md`. +## 📊 Benchmarks (vs nalgebra) + +![LU solve (factor + solve): median time vs dimension](docs/assets/bench/vs_nalgebra_lu_solve_median.svg) + +Raw data: [docs/assets/bench/vs_nalgebra_lu_solve_median.csv](docs/assets/bench/vs_nalgebra_lu_solve_median.csv) + +Summary (median time; lower is better). “la-stack vs nalgebra” is the % time reduction relative to nalgebra (positive = la-stack faster): + + +| D | la-stack median (ns) | nalgebra median (ns) | la-stack vs nalgebra | +|---:|--------------------:|--------------------:|---------------------:| +| 2 | 2.125 | 19.172 | +88.9% | +| 3 | 13.562 | 24.082 | +43.7% | +| 4 | 28.365 | 55.434 | +48.8% | +| 5 | 48.567 | 76.793 | +36.8% | +| 8 | 141.935 | 182.628 | +22.3% | +| 16 | 642.935 | 605.115 | -6.3% | +| 32 | 2,761.816 | 2,505.691 | -10.2% | +| 64 | 17,009.208 | 14,696.410 | -15.7% | + + ## 📄 License BSD 3-Clause License. See [LICENSE](./LICENSE). diff --git a/benches/vs_nalgebra.rs b/benches/vs_nalgebra.rs index f96de7b..70bd4a2 100644 --- a/benches/vs_nalgebra.rs +++ b/benches/vs_nalgebra.rs @@ -1,39 +1,265 @@ -//! Minimal benchmark harness. +//! Benchmark comparison between la-stack and nalgebra. //! -//! This exists so the `[[bench]]` target in `Cargo.toml` is valid and `cargo test` -//! can parse the manifest. It also provides a small comparison point against -//! `nalgebra` for determinant computation. +//! Goal: like-for-like comparisons of the operations la-stack supports across several +//! fixed dimensions. +//! +//! Notes: +//! - Determinant is benchmarked via LU on both sides (nalgebra uses closed-forms for 1×1/2×2/3×3). +//! - Matrix infinity norm is the maximum absolute row sum on both sides. use criterion::Criterion; +use pastey::paste; use std::hint::black_box; +#[inline] +#[allow(clippy::cast_precision_loss)] // D, r, c are small integers, precision loss is not an issue. +fn matrix_entry(r: usize, c: usize) -> f64 { + if r == c { + // Strict diagonal dominance for stability. + (r as f64).mul_add(1.0e-3, (D as f64) + 1.0) + } else { + // Small, varying off-diagonals. + 0.1 / ((r + c + 1) as f64) + } +} + +#[inline] +fn make_matrix_rows() -> [[f64; D]; D] { + let mut rows = [[0.0; D]; D]; + + let mut r = 0; + while r < D { + let mut c = 0; + while c < D { + rows[r][c] = matrix_entry::(r, c); + c += 1; + } + r += 1; + } + + rows +} + +#[inline] +#[allow(clippy::cast_precision_loss)] // i is a small integer, precision loss is not an issue. +fn vector_entry(i: usize, offset: f64) -> f64 { + (i as f64) + 1.0 + offset +} + +#[inline] +fn make_vector_array(offset: f64) -> [f64; D] { + let mut data = [0.0; D]; + + let mut i = 0; + while i < D { + data[i] = vector_entry(i, offset); + i += 1; + } + + data +} + +#[inline] +fn nalgebra_inf_norm(m: &nalgebra::SMatrix) -> f64 { + // Infinity norm = max absolute row sum. + let mut max_row_sum = 0.0; + + let mut r = 0; + while r < D { + let mut row_sum = 0.0; + let mut c = 0; + while c < D { + row_sum += m[(r, c)].abs(); + c += 1; + } + if row_sum > max_row_sum { + max_row_sum = row_sum; + } + r += 1; + } + + max_row_sum +} + +macro_rules! gen_vs_nalgebra_benches_for_dim { + ($c:expr, $d:literal) => { + paste! {{ + // Isolate each dimension's inputs to keep types and captures clean. + { + let a = la_stack::Matrix::<$d>::from_rows(make_matrix_rows::<$d>()); + let rhs = la_stack::Vector::<$d>::new(make_vector_array::<$d>(0.0)); + let v1 = la_stack::Vector::<$d>::new(make_vector_array::<$d>(0.0)); + let v2 = la_stack::Vector::<$d>::new(make_vector_array::<$d>(1.0)); + + let na = nalgebra::SMatrix::::from_fn(|r, c| matrix_entry::<$d>(r, c)); + let nrhs = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 0.0)); + let nv1 = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 0.0)); + let nv2 = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 1.0)); + + // Precompute LU once for solve-only / det-only benchmarks. + let a_lu = a + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let na_lu = na.clone().lu(); + + let mut [] = ($c).benchmark_group(concat!("d", stringify!($d))); + + // === Determinant via LU (factor + det) === + [].bench_function("la_stack_det_via_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(a) + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let det = lu.det(); + black_box(det); + }); + }); + + [].bench_function("nalgebra_det_via_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(na.clone()).lu(); + let det = lu.determinant(); + black_box(det); + }); + }); + + // === LU factorization === + [].bench_function("la_stack_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(a) + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let _ = black_box(lu); + }); + }); + + [].bench_function("nalgebra_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(na.clone()).lu(); + black_box(lu); + }); + }); + + // === LU solve (factor + solve) === + [].bench_function("la_stack_lu_solve", |bencher| { + bencher.iter(|| { + let lu = black_box(a) + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let x = lu + .solve_vec(black_box(rhs)) + .expect("solve should succeed"); + let _ = black_box(x); + }); + }); + + [].bench_function("nalgebra_lu_solve", |bencher| { + bencher.iter(|| { + let lu = black_box(na.clone()).lu(); + let x = lu + .solve(black_box(&nrhs)) + .expect("solve should succeed"); + black_box(x); + }); + }); + + // === Solve using a precomputed LU === + [].bench_function("la_stack_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = a_lu + .solve_vec(black_box(rhs)) + .expect("solve should succeed"); + let _ = black_box(x); + }); + }); + + [].bench_function("nalgebra_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = na_lu + .solve(black_box(&nrhs)) + .expect("solve should succeed"); + black_box(x); + }); + }); + + // === Determinant from a precomputed LU === + [].bench_function("la_stack_det_from_lu", |bencher| { + bencher.iter(|| { + let det = a_lu.det(); + black_box(det); + }); + }); + + [].bench_function("nalgebra_det_from_lu", |bencher| { + bencher.iter(|| { + let det = na_lu.determinant(); + black_box(det); + }); + }); + + // === Vector dot product === + [].bench_function("la_stack_dot", |bencher| { + bencher.iter(|| { + let result = black_box(v1).dot(black_box(v2)); + black_box(result); + }); + }); + + [].bench_function("nalgebra_dot", |bencher| { + bencher.iter(|| { + let result = black_box(&nv1).dot(black_box(&nv2)); + black_box(result); + }); + }); + + // === Vector norm squared === + [].bench_function("la_stack_norm2_sq", |bencher| { + bencher.iter(|| { + let result = black_box(v1).norm2_sq(); + black_box(result); + }); + }); + + [].bench_function("nalgebra_norm_squared", |bencher| { + bencher.iter(|| { + let result = black_box(&nv1).norm_squared(); + black_box(result); + }); + }); + + // === Matrix infinity norm (max absolute row sum) === + [].bench_function("la_stack_inf_norm", |bencher| { + bencher.iter(|| { + let result = black_box(a).inf_norm(); + black_box(result); + }); + }); + + [].bench_function("nalgebra_inf_norm", |bencher| { + bencher.iter(|| { + let result = nalgebra_inf_norm::<$d>(black_box(&na)); + black_box(result); + }); + }); + + [].finish(); + } + }} + }; +} + fn main() { let mut c = Criterion::default().configure_from_args(); - // Small, fixed-size system representative of the geometric use cases. - let a = la_stack::Matrix::<3>::from_rows([[1.0, 2.0, 3.0], [0.5, -4.0, 0.25], [7.0, 0.0, 1.0]]); - - let na = nalgebra::Matrix3::new( - 1.0, 2.0, 3.0, // - 0.5, -4.0, 0.25, // - 7.0, 0.0, 1.0, - ); - - c.bench_function("la_stack_det_3x3", |b| { - b.iter(|| { - let det = black_box(a) - .det(la_stack::DEFAULT_PIVOT_TOL) - .expect("matrix should be non-singular"); - black_box(det); - }); - }); - - c.bench_function("nalgebra_det_3x3", |b| { - b.iter(|| { - let det = black_box(na).determinant(); - black_box(det); - }); - }); + gen_vs_nalgebra_benches_for_dim!(&mut c, 2); + gen_vs_nalgebra_benches_for_dim!(&mut c, 3); + gen_vs_nalgebra_benches_for_dim!(&mut c, 4); + gen_vs_nalgebra_benches_for_dim!(&mut c, 5); + + gen_vs_nalgebra_benches_for_dim!(&mut c, 8); + gen_vs_nalgebra_benches_for_dim!(&mut c, 16); + gen_vs_nalgebra_benches_for_dim!(&mut c, 32); + gen_vs_nalgebra_benches_for_dim!(&mut c, 64); c.final_summary(); } diff --git a/cspell.json b/cspell.json index 079bbde..db6a87d 100644 --- a/cspell.json +++ b/cspell.json @@ -4,10 +4,12 @@ "useGitignore": true, "words": [ "acgetchell", + "blas", "Clippy", "clippy", "codacy", "const", + "datafile", "doctests", "elif", "endgroup", @@ -16,34 +18,55 @@ "f64", "generics", "Getchell", + "gnuplot", "Justfile", "justfile", + "keepends", "laerror", + "lapack", + "linespoints", + "logscale", "lu", "markdownlint", "MSRV", "msvc", "mult", + "mypy", "nalgebra", + "noenhanced", + "nomirror", "nonfinite", + "noplot", + "nrhs", + "openblas", "pastey", + "patchlevel", "pipefail", "pivoting", "println", "proptest", "proptests", + "pycache", + "pytest", "rug", "RUSTDOCFLAGS", "sarif", "semgrep", + "setuptools", "shellcheck", "taiki", "tridiagonal", "unittests", - "usize" + "usize", + "venv", + "xlabel", + "xtics", + "ylabel", + "yerrorlines" ], "ignorePaths": [ "**/.git/**", + "**/*.svg", "target", "Cargo.lock" ] diff --git a/docs/assets/bench/.gitkeep b/docs/assets/bench/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/assets/bench/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/assets/bench/vs_nalgebra_lu_solve_median.csv b/docs/assets/bench/vs_nalgebra_lu_solve_median.csv new file mode 100644 index 0000000..e2145de --- /dev/null +++ b/docs/assets/bench/vs_nalgebra_lu_solve_median.csv @@ -0,0 +1,9 @@ +D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi +2,2.124532914367211,2.09971523996188,2.140164099914812,19.17217328467681,18.879067968718168,19.28482481777221 +3,13.561910796179443,13.524685753541656,13.620824746096288,24.082432810332314,23.94661978721645,24.233110558617877 +4,28.365434970029426,28.115956502260637,28.502018158281214,55.43414489536336,54.85631819804828,55.91912759333677 +5,48.5674565592533,47.968475042662924,48.699512778705056,76.79332103126086,75.63044466439385,77.34876828329485 +8,141.9353177287798,140.58909817870264,142.80253467051537,182.6280500376712,181.07586039158457,184.98799180091783 +16,642.9353974725984,635.2079326923077,653.6867862529627,605.1153810926868,600.7585890927475,613.4346656932975 +32,2761.8159597172203,2748.3301012712777,2778.877640467952,2505.6908196956647,2491.7890656874747,2545.825832615561 +64,17009.207815892314,16940.164226190478,17056.327922077922,14696.410081665515,14596.956030459347,14746.887774061686 diff --git a/docs/assets/bench/vs_nalgebra_lu_solve_median.svg b/docs/assets/bench/vs_nalgebra_lu_solve_median.svg new file mode 100644 index 0000000..0355730 --- /dev/null +++ b/docs/assets/bench/vs_nalgebra_lu_solve_median.svg @@ -0,0 +1,377 @@ + + + +Gnuplot +Produced by GNUPLOT 6.0 patchlevel 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + 10 + + + + + + + + + + + + + 100 + + + + + + + + + + + + + 1000 + + + + + + + + + + + + + 10000 + + + + + + + + + + + + + 100000 + + + + + + + + + + + + + 2 + + + + + + + + + + + + + 3 + + + + + + + + + + + + + 4 + + + + + + + + + + + + + 5 + + + + + + + + + + + + + 8 + + + + + + + + + + + + + 16 + + + + + + + + + + + + + 32 + + + + + + + + + + + + + 64 + + + + + + + + + gnuplot_plot_1 + + + + + la-stack + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + gnuplot_plot_2 + + + nalgebra + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + median time (ns) + + + + + Dimension D + + + + + + + LU solve (factor + solve): median time vs dimension + + + + + + + diff --git a/justfile b/justfile index 24348e4..958272e 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,24 @@ # Use bash with strict error handling for all recipes set shell := ["bash", "-euo", "pipefail", "-c"] +# Internal helper: ensure uv is installed +_ensure-uv: + #!/usr/bin/env bash + set -euo pipefail + command -v uv >/dev/null || { echo "❌ 'uv' not found. Install: https://github.com/astral-sh/uv (macOS: brew install uv)"; exit 1; } + +# Python tooling (uv) +python-sync: _ensure-uv + uv sync --group dev + +python-lint: python-sync + uv run ruff check scripts/ --fix + uv run ruff format scripts/ + uv run mypy scripts/criterion_dim_plot.py + +test-python: python-sync + uv run pytest -q + # GitHub Actions workflow validation (optional) action-lint: #!/usr/bin/env bash @@ -28,6 +46,48 @@ action-lint: bench: cargo bench +# Bench the la-stack vs nalgebra comparison suite. +bench-vs-nalgebra filter="": + #!/usr/bin/env bash + set -euo pipefail + filter="{{filter}}" + if [ -n "$filter" ]; then + cargo bench --bench vs_nalgebra -- "$filter" + else + cargo bench --bench vs_nalgebra + fi + +# Quick iteration (reduced runtime, no Criterion HTML). +bench-vs-nalgebra-quick filter="": + #!/usr/bin/env bash + set -euo pipefail + filter="{{filter}}" + if [ -n "$filter" ]; then + cargo bench --bench vs_nalgebra -- "$filter" --quick --noplot + else + cargo bench --bench vs_nalgebra -- --quick --noplot + fi + +# Plot: generate a single time-vs-dimension SVG from Criterion results. +plot-vs-nalgebra metric="lu_solve" stat="median" sample="new" log_y="false": python-sync + #!/usr/bin/env bash + set -euo pipefail + args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}") + if [ "{{log_y}}" = "true" ]; then + args+=(--log-y) + fi + uv run criterion-dim-plot "${args[@]}" + +# Plot + update the README benchmark table between BENCH_TABLE markers. +plot-vs-nalgebra-readme metric="lu_solve" stat="median" sample="new" log_y="false": python-sync + #!/usr/bin/env bash + set -euo pipefail + args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}" --update-readme) + if [ "{{log_y}}" = "true" ]; then + args+=(--log-y) + fi + uv run criterion-dim-plot "${args[@]}" + bench-compile: cargo bench --no-run @@ -113,7 +173,7 @@ fmt-check: # Lint groups (delaunay-style) lint: lint-code lint-docs lint-config -lint-code: fmt-check clippy doc-check +lint-code: fmt-check clippy doc-check python-lint lint-config: validate-json action-lint @@ -152,14 +212,14 @@ spell-check: # Testing (delaunay-style split) # - test: lib + doc tests (fast) -# - test-all: everything in Rust +# - test-all: all tests (Rust + Python) # - test-integration: tests/ (if present) test: cargo test --lib --verbose cargo test --doc --verbose -test-all: test test-integration +test-all: test test-integration test-python @echo "✅ All tests passed" test-integration: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ded8543 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,108 @@ +[build-system] +requires = ["setuptools>=65.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "la-stack-scripts" +version = "0.1.0" +description = "Python utility scripts for the la-stack Rust library" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "BSD-3-Clause"} +authors = [ + {name = "Adam Getchell", email = "adam@adamgetchell.org"}, +] +keywords = ["linear-algebra", "benchmarking", "utilities", "la-stack"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: System :: Benchmarking", +] + +# No runtime dependencies currently; scripts rely on the standard library. +dependencies = [] + +[project.urls] +"Homepage" = "https://github.com/acgetchell/la-stack" +"Documentation" = "https://docs.rs/la-stack" +"Repository" = "https://github.com/acgetchell/la-stack" +"Bug Tracker" = "https://github.com/acgetchell/la-stack/issues" + +[project.scripts] +criterion-dim-plot = "criterion_dim_plot:main" + +# Configure setuptools to find modules in scripts/ directory. +[tool.setuptools] +package-dir = {"" = "scripts"} +py-modules = ["criterion_dim_plot"] + +[tool.ruff] +line-length = 150 +target-version = "py311" +src = ["scripts"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "YTT", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "EXE", "ISC", "ICN", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PD", "PGH", "PL", "TRY", "NPY", "RUF"] +fixable = ["ALL"] +unfixable = [] +ignore = [ + # Formatter conflicts + "COM812", # Trailing comma missing (conflicts with formatter) + "ISC001", # Implicitly concatenated string literals (conflicts with formatter) + + # CLI script patterns + "PLR2004", # Magic value used in comparison - OK for CLI constants and thresholds + "FBT001", # Boolean-typed positional argument - OK for CLI flags + "FBT002", # Boolean default positional argument - OK for CLI flags + "BLE001", # Do not catch blind exception - OK for CLI robustness + "T201", # print found - OK for CLI output + "TRY300", # Consider moving statement to else block - OK for CLI control flow + "TRY301", # Abstract raise to inner function - OK for straightforward CLI error handling + "ARG001", # Unused function argument - common in callbacks + "ERA001", # Found commented-out code - OK for explanatory comments + "EXE001", # Shebang present but file not executable - handled by packaging + "PTH123", # open() should be replaced by Path.open() - OK for some call sites + "EM102", # Exception must not use f-string - OK for CLI error messages + "TRY003", # Avoid specifying long messages outside exception class - OK for CLI reporting +] + +[tool.ruff.lint.per-file-ignores] +"scripts/tests/**/*.py" = [ + "S101", # asserts are fine in tests + "SLF001", # tests may call internal helpers +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +python_version = "3.11" +mypy_path = "scripts" +warn_unused_configs = true +no_implicit_optional = true +strict_equality = true +warn_redundant_casts = true +warn_no_return = true +show_error_codes = true +show_column_numbers = true +pretty = true +allow_untyped_calls = true +allow_incomplete_defs = true + +[dependency-groups] +dev = [ + "mypy>=1.19.0", + "pytest>=8.0.0", + "ruff>=0.12.11", +] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..43e53a6 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,120 @@ +# scripts/ + +This directory contains Python utilities used during development of the `la-stack` crate. + +## Setup + +The Python tooling in this repo is managed with [`uv`](https://github.com/astral-sh/uv). + +```bash +just python-sync +# or: +uv sync --group dev +``` + +## How to use it + +### Plotting Criterion benchmarks (la-stack vs nalgebra) + +The plotter reads Criterion output under: + +- `target/criterion/d{D}/{benchmark}/{new|base}/estimates.json` + +And writes: + +- `docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv` +- `docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg` + +To generate the single “time vs dimension” chart: + +By default, the benchmark suite runs for dimensions 2–5, 8, 16, 32, and 64. + +1. Run the benchmarks you want to plot (this produces `target/criterion/...`): + +```bash +# full run (takes longer, better for README plots) +just bench-vs-nalgebra lu_solve + +# or quick run (fast sanity check; still produces estimates.json) +just bench-vs-nalgebra-quick lu_solve +``` + +2. Generate the chart (median or mean): + +```bash +# median (recommended) +just plot-vs-nalgebra lu_solve median new true + +# median + update README's benchmark table (between BENCH_TABLE markers) +just plot-vs-nalgebra-readme lu_solve median new true + +# or mean +just plot-vs-nalgebra lu_solve mean new true +``` + +This writes: + +- `docs/assets/bench/vs_nalgebra_lu_solve_median.csv` +- `docs/assets/bench/vs_nalgebra_lu_solve_median.svg` (requires `gnuplot`) + +(For `stat=mean`, the filenames end in `_mean` instead of `_median`.) + +### More examples + +Plot a different metric: + +```bash +uv run criterion-dim-plot --metric dot --stat median --sample new +uv run criterion-dim-plot --metric inf_norm --stat median --sample new +``` + +Plot a different statistic: + +```bash +uv run criterion-dim-plot --metric lu_solve --stat mean --sample new +``` + +Plot the previous (baseline) sample instead of the newest run: + +```bash +uv run criterion-dim-plot --metric lu_solve --stat median --sample base +``` + +Use a log-scale y-axis: + +```bash +uv run criterion-dim-plot --metric lu_solve --stat median --sample new --log-y +``` + +Write to custom output paths: + +```bash +uv run criterion-dim-plot \ + --metric lu_solve --stat median --sample new \ + --csv docs/assets/bench/custom.csv \ + --out docs/assets/bench/custom.svg +``` + +CSV only (skip SVG/gnuplot): + +```bash +uv run criterion-dim-plot --no-plot --metric lu_solve --stat median --sample new +``` + +### gnuplot + +SVG rendering requires `gnuplot` to be installed and available on `PATH`. + +Install (macOS/Homebrew): + +```bash +brew install gnuplot +``` + +Verify the installed version: + +```bash +gnuplot --version +``` + +This repo has been tested with `gnuplot 6.0 patchlevel 3` (Homebrew `gnuplot 6.0.3`). diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py new file mode 100644 index 0000000..ef3b34a --- /dev/null +++ b/scripts/criterion_dim_plot.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +"""Aggregate Criterion benchmark results into a time-vs-dimension chart. + +Reads Criterion output under: + target/criterion/d{D}/{benchmark}/{new|base}/estimates.json + +And writes: + docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv + docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg + +This is intended to create a single, README-friendly plot comparing la-stack to nalgebra +across dimensions. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Final + + +@dataclass(frozen=True, slots=True) +class Metric: + la_bench: str + na_bench: str + title: str + + +@dataclass(frozen=True, slots=True) +class PlotRequest: + csv_path: Path + out_svg: Path + title: str + stat: str + dims: tuple[int, ...] + log_y: bool + + +METRICS: Final[dict[str, Metric]] = { + "det_via_lu": Metric( + la_bench="la_stack_det_via_lu", + na_bench="nalgebra_det_via_lu", + title="Determinant via LU (factor + det)", + ), + "lu": Metric( + la_bench="la_stack_lu", + na_bench="nalgebra_lu", + title="LU factorization", + ), + "lu_solve": Metric( + la_bench="la_stack_lu_solve", + na_bench="nalgebra_lu_solve", + title="LU solve (factor + solve)", + ), + "solve_from_lu": Metric( + la_bench="la_stack_solve_from_lu", + na_bench="nalgebra_solve_from_lu", + title="Solve from precomputed LU", + ), + "det_from_lu": Metric( + la_bench="la_stack_det_from_lu", + na_bench="nalgebra_det_from_lu", + title="Determinant from precomputed LU", + ), + "dot": Metric( + la_bench="la_stack_dot", + na_bench="nalgebra_dot", + title="Vector dot product", + ), + # Different names between crates. + "norm2_sq": Metric( + la_bench="la_stack_norm2_sq", + na_bench="nalgebra_norm_squared", + title="Vector squared 2-norm", + ), + "inf_norm": Metric( + la_bench="la_stack_inf_norm", + na_bench="nalgebra_inf_norm", + title="Matrix infinity norm (max abs row sum)", + ), +} + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _dim_from_group_dir(name: str) -> int | None: + match = re.fullmatch(r"d(\d+)", name) + if match is None: + return None + return int(match.group(1)) + + +def _discover_dims(criterion_dir: Path) -> list[int]: + dims: list[int] = [] + for child in criterion_dir.iterdir(): + if not child.is_dir(): + continue + d = _dim_from_group_dir(child.name) + if d is None: + continue + dims.append(d) + return sorted(dims) + + +def _read_estimate(estimates_json: Path, stat: str) -> tuple[float, float, float]: + data = json.loads(estimates_json.read_text(encoding="utf-8")) + + stat_obj = data.get(stat) + if not isinstance(stat_obj, dict): + raise KeyError(f"stat '{stat}' not found in {estimates_json}") + + point = float(stat_obj["point_estimate"]) + ci = stat_obj.get("confidence_interval") + if not isinstance(ci, dict): + return (point, point, point) + + lo = float(ci.get("lower_bound", point)) + hi = float(ci.get("upper_bound", point)) + return (point, lo, hi) + + +def _write_csv(out_csv: Path, rows: list[tuple[int, float, float, float, float, float, float]]) -> None: + out_csv.parent.mkdir(parents=True, exist_ok=True) + with out_csv.open("w", encoding="utf-8") as f: + f.write("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi\n") + for d, la, la_lo, la_hi, na, na_lo, na_hi in rows: + f.write(f"{d},{la},{la_lo},{la_hi},{na},{na_lo},{na_hi}\n") + + +def _markdown_table(rows: list[tuple[int, float, float, float, float, float, float]], stat: str) -> str: + lines = [ + f"| D | la-stack {stat} (ns) | nalgebra {stat} (ns) | la-stack vs nalgebra |", + "|---:|--------------------:|--------------------:|---------------------:|", + ] + + for d, la, _la_lo, _la_hi, na, _na_lo, _na_hi in rows: + pct = ((na - la) / na) * 100.0 + lines.append(f"| {d} | {la:,.3f} | {na:,.3f} | {pct:+.1f}% |") + + return "\n".join(lines) + + +def _readme_table_markers(metric: str, stat: str, sample: str) -> tuple[str, str]: + tag = f"BENCH_TABLE:{metric}:{stat}:{sample}" + return (f"", f"") + + +def _update_readme_table(readme_path: Path, marker_begin: str, marker_end: str, table_md: str) -> bool: + lines = readme_path.read_text(encoding="utf-8").splitlines(keepends=True) + + begin_indices = [i for i, line in enumerate(lines) if line.strip() == marker_begin] + end_indices = [i for i, line in enumerate(lines) if line.strip() == marker_end] + + if len(begin_indices) != 1 or len(end_indices) != 1: + raise ValueError(f"README markers not found or not unique. Expected exactly one of each:\n {marker_begin}\n {marker_end}\n") + + begin_idx = begin_indices[0] + end_idx = end_indices[0] + if begin_idx >= end_idx: + msg = "README markers are out of order." + raise ValueError(msg) + + table_lines = [line + "\n" for line in table_md.strip("\n").splitlines()] + new_lines = [ + *lines[: begin_idx + 1], + *table_lines, + *lines[end_idx:], + ] + + if new_lines == lines: + return False + + readme_path.write_text("".join(new_lines), encoding="utf-8") + return True + + +def _gp_quote(s: str) -> str: + # gnuplot supports single-quoted strings; escape single quotes. + return "'" + s.replace("'", "\\'") + "'" + + +def _render_svg_with_gnuplot(req: PlotRequest) -> None: + gnuplot_path = shutil.which("gnuplot") + if gnuplot_path is None: + msg = "gnuplot not found. Install it (macOS: `brew install gnuplot`) or re-run with --no-plot." + raise FileNotFoundError(msg) + + req.out_svg.parent.mkdir(parents=True, exist_ok=True) + + xtics = ", ".join(str(d) for d in req.dims) + + gp_lines = [ + "set terminal svg size 960,540 noenhanced", + f"set output {_gp_quote(str(req.out_svg))}", + "set datafile separator comma", + "set grid", + "set key left top", + f"set title {_gp_quote(req.title)}", + "set xlabel 'Dimension D'", + f"set ylabel {_gp_quote(f'{req.stat} time (ns)')}", + f"set xtics ({xtics})", + "set style line 1 lc rgb '#1f77b4' lt 1 lw 2 pt 7 ps 1", + "set style line 2 lc rgb '#ff7f0e' lt 1 lw 2 pt 5 ps 1", + "set style data linespoints", + "set tics nomirror", + "set border linewidth 1", + ] + + if req.log_y: + gp_lines.append("set logscale y 10") + + gp_lines.extend( + [ + "plot \\", + f" {_gp_quote(str(req.csv_path))} using 1:2:3:4 with yerrorlines ls 1 title 'la-stack', \\", + f" {_gp_quote(str(req.csv_path))} using 1:5:6:7 with yerrorlines ls 2 title 'nalgebra'", + ] + ) + + # Safe: gnuplot executable is resolved via PATH; input is a generated script with fully + # quoted file paths. + subprocess.run([gnuplot_path], input="\n".join(gp_lines), text=True, check=True) # noqa: S603 + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plot Criterion time vs dimension for la-stack vs nalgebra.") + + parser.add_argument( + "--metric", + default="lu_solve", + choices=sorted(METRICS.keys()), + help="Which vs_nalgebra metric to plot.", + ) + parser.add_argument( + "--stat", + default="median", + choices=["mean", "median"], + help="Statistic to plot from estimates.json.", + ) + parser.add_argument( + "--sample", + default="new", + choices=["new", "base"], + help="Which Criterion run directory to read (new = most recent).", + ) + parser.add_argument( + "--criterion-dir", + default="target/criterion", + help="Criterion output directory (default: target/criterion).", + ) + parser.add_argument( + "--out", + default=None, + help="Output SVG path (default: docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg).", + ) + parser.add_argument( + "--csv", + default=None, + help="Output CSV path (default: docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv).", + ) + parser.add_argument( + "--log-y", + action="store_true", + help="Use a log-scale y-axis.", + ) + parser.add_argument( + "--no-plot", + action="store_true", + help="Only write CSV (skip gnuplot/SVG).", + ) + parser.add_argument( + "--update-readme", + action="store_true", + help="Update a Markdown table in README.md between BENCH_TABLE markers.", + ) + parser.add_argument( + "--readme", + default="README.md", + help="Path to README file to update (default: README.md at repo root).", + ) + + return parser.parse_args(argv) + + +Row = tuple[int, float, float, float, float, float, float] + + +def _resolve_under_root(root: Path, arg: str) -> Path: + path = Path(arg) + return path if path.is_absolute() else root / path + + +def _resolve_output_paths(root: Path, metric: str, stat: str, out_svg: str | None, out_csv: str | None) -> tuple[Path, Path]: + svg = Path(out_svg) if out_svg is not None else Path(f"docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg") + csv = Path(out_csv) if out_csv is not None else Path(f"docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv") + + if not svg.is_absolute(): + svg = root / svg + if not csv.is_absolute(): + csv = root / csv + + return (svg, csv) + + +def _collect_rows(criterion_dir: Path, dims: list[int], metric: Metric, stat: str, sample: str) -> tuple[list[Row], list[str]]: + rows: list[Row] = [] + skipped: list[str] = [] + + for d in dims: + group_dir = criterion_dir / f"d{d}" + la_est = group_dir / metric.la_bench / sample / "estimates.json" + na_est = group_dir / metric.na_bench / sample / "estimates.json" + + if not la_est.exists() or not na_est.exists(): + skipped.append(f"d{d} (missing {metric.la_bench} or {metric.na_bench})") + continue + + la, la_lo, la_hi = _read_estimate(la_est, stat) + na, na_lo, na_hi = _read_estimate(na_est, stat) + rows.append((d, la, la_lo, la_hi, na, na_lo, na_hi)) + + return (rows, skipped) + + +def _maybe_update_readme(root: Path, args: argparse.Namespace, rows: list[Row]) -> int: + if not args.update_readme: + return 0 + + readme_path = _resolve_under_root(root, args.readme) + + marker_begin, marker_end = _readme_table_markers(args.metric, args.stat, args.sample) + table_md = _markdown_table(rows, args.stat) + + try: + changed = _update_readme_table(readme_path, marker_begin, marker_end, table_md) + except (OSError, ValueError) as e: + print(str(e), file=sys.stderr) + return 2 + + if changed: + print(f"Updated README table: {readme_path}") + + return 0 + + +def _maybe_render_plot(args: argparse.Namespace, req: PlotRequest, skipped: list[str]) -> int: + if args.no_plot: + print(f"Wrote CSV: {req.csv_path}") + return 0 + + try: + _render_svg_with_gnuplot(req) + except FileNotFoundError as e: + print(str(e), file=sys.stderr) + print(f"Wrote CSV instead: {req.csv_path}", file=sys.stderr) + return 1 + + if skipped: + print("Warning: some dimension groups were skipped:") + for s in skipped: + print(f" - {s}") + + print(f"Wrote CSV: {req.csv_path}") + print(f"Wrote SVG: {req.out_svg}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(sys.argv[1:] if argv is None else argv) + + root = _repo_root() + + criterion_dir = _resolve_under_root(root, args.criterion_dir) + + dims = _discover_dims(criterion_dir) if criterion_dir.exists() else [] + if not dims: + print( + f"No Criterion results found under {criterion_dir}.\n\nRun benchmarks first, e.g.:\n cargo bench --bench vs_nalgebra\n", + file=sys.stderr, + ) + return 2 + + metric = METRICS[args.metric] + + out_svg, out_csv = _resolve_output_paths(root, args.metric, args.stat, args.out, args.csv) + + rows, skipped = _collect_rows(criterion_dir, dims, metric, args.stat, args.sample) + if not rows: + print( + "No benchmark results found to plot for the selected metric/stat.\n" + f"Expected files like:\n {criterion_dir}/d32/{metric.la_bench}/{args.sample}/estimates.json\n", + file=sys.stderr, + ) + if skipped: + print("Skipped groups:", *skipped, sep="\n - ", file=sys.stderr) + return 2 + + _write_csv(out_csv, rows) + + rc = _maybe_update_readme(root, args, rows) + if rc != 0: + return rc + + dims_present = [d for (d, *_rest) in rows] + + title = f"{metric.title}: {args.stat} time vs dimension" + req = PlotRequest( + csv_path=out_csv, + out_svg=out_svg, + title=title, + stat=args.stat, + dims=tuple(dims_present), + log_y=args.log_y, + ) + + return _maybe_render_plot(args, req, skipped) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py new file mode 100644 index 0000000..62bfca1 --- /dev/null +++ b/scripts/tests/test_criterion_dim_plot.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +import criterion_dim_plot + +if TYPE_CHECKING: + from pathlib import Path + + +def test_readme_table_markers_are_stable() -> None: + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + assert begin == "" + assert end == "" + + +def test_markdown_table_formats_values_and_pct() -> None: + rows = [ + # (D, la, la_lo, la_hi, na, na_lo, na_hi) + (2, 50.0, 0.0, 0.0, 100.0, 0.0, 0.0), # +50.0% + (64, 1_000.0, 0.0, 0.0, 900.0, 0.0, 0.0), # -11.1% + ] + + table = criterion_dim_plot._markdown_table(rows, stat="median") + + assert "| D | la-stack median (ns) | nalgebra median (ns) | la-stack vs nalgebra |" in table + assert "| 2 | 50.000 | 100.000 | +50.0% |" in table + # thousand separator and sign + assert "| 64 | 1,000.000 | 900.000 | -11.1% |" in table + + +def test_update_readme_table_replaces_only_between_markers(tmp_path: Path) -> None: + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + + readme = tmp_path / "README.md" + readme.write_text( + "\n".join( + [ + "# Title", + "before", + marker_begin, + "old line 1", + "old line 2", + marker_end, + "after", + "", + ] + ), + encoding="utf-8", + ) + + table_md = "| a |\n|---|\n| 1 |" + + changed = criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, table_md) + assert changed is True + + text = readme.read_text(encoding="utf-8") + assert "old line 1" not in text + assert "old line 2" not in text + assert marker_begin in text + assert marker_end in text + assert "| a |" in text + + # Re-running with the same content should be a no-op. + changed_again = criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, table_md) + assert changed_again is False + + +def test_update_readme_table_errors_on_missing_markers(tmp_path: Path) -> None: + readme = tmp_path / "README.md" + readme.write_text("# Title\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"README markers not found"): + criterion_dim_plot._update_readme_table( + readme, + "", + "", + "| x |", + ) + + +def test_update_readme_table_errors_on_out_of_order_markers(tmp_path: Path) -> None: + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + + readme = tmp_path / "README.md" + readme.write_text("\n".join([marker_end, marker_begin, ""]), encoding="utf-8") + + with pytest.raises(ValueError, match=r"out of order"): + criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") + + +def test_update_readme_table_errors_on_non_unique_markers(tmp_path: Path) -> None: + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + + readme = tmp_path / "README.md" + readme.write_text( + "\n".join( + [ + marker_begin, + marker_begin, + marker_end, + "", + ] + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match=r"not found or not unique"): + criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") + + +def test_main_update_readme_no_plot_happy_path(tmp_path: Path) -> None: + # Create a minimal Criterion directory structure for lu_solve. + criterion_dir = tmp_path / "criterion" + + def write_estimates(path: Path, median: float) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "median": { + "point_estimate": median, + "confidence_interval": {"lower_bound": median * 0.9, "upper_bound": median * 1.1}, + } + } + ), + encoding="utf-8", + ) + + for d, la, na in [(2, 10.0, 20.0), (8, 100.0, 50.0)]: + base = criterion_dir / f"d{d}" + write_estimates(base / "la_stack_lu_solve" / "new" / "estimates.json", la) + write_estimates(base / "nalgebra_lu_solve" / "new" / "estimates.json", na) + + readme = tmp_path / "README.md" + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + readme.write_text("\n".join(["# Bench", marker_begin, "placeholder", marker_end, ""]), encoding="utf-8") + + out_csv = tmp_path / "out.csv" + + rc = criterion_dim_plot.main( + [ + "--metric", + "lu_solve", + "--stat", + "median", + "--sample", + "new", + "--criterion-dir", + str(criterion_dir), + "--csv", + str(out_csv), + "--no-plot", + "--update-readme", + "--readme", + str(readme), + ] + ) + assert rc == 0 + + # CSV written + csv_text = out_csv.read_text(encoding="utf-8") + assert csv_text.startswith("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi\n") + assert "2,10.0" in csv_text + assert "8,100.0" in csv_text + + # README updated with computed table + readme_text = readme.read_text(encoding="utf-8") + assert "placeholder" not in readme_text + assert "| 2 | 10.000 | 20.000 | +50.0% |" in readme_text + assert "| 8 | 100.000 | 50.000 | -100.0% |" in readme_text diff --git a/src/lu.rs b/src/lu.rs index 7c04bfe..92ab801 100644 --- a/src/lu.rs +++ b/src/lu.rs @@ -86,7 +86,6 @@ impl Lu { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { @@ -152,7 +151,6 @@ impl Lu { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { diff --git a/src/matrix.rs b/src/matrix.rs index 3eff55a..6d7a5bb 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -15,7 +15,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let m = Matrix::<2>::from_rows([[1.0, 2.0], [3.0, 4.0]]); @@ -30,7 +29,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let z = Matrix::<2>::zero(); @@ -47,7 +45,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let i = Matrix::<3>::identity(); @@ -72,7 +69,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let m = Matrix::<2>::from_rows([[1.0, 2.0], [3.0, 4.0]]); @@ -95,7 +91,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let mut m = Matrix::<2>::zero(); @@ -117,7 +112,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let m = Matrix::<2>::from_rows([[1.0, -2.0], [3.0, 4.0]]); @@ -142,7 +136,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { @@ -170,7 +163,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { diff --git a/src/vector.rs b/src/vector.rs index e63c3c2..6660587 100644 --- a/src/vector.rs +++ b/src/vector.rs @@ -12,7 +12,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<3>::new([1.0, 2.0, 3.0]); @@ -27,7 +26,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let z = Vector::<2>::zero(); @@ -42,7 +40,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<2>::new([1.0, -2.0]); @@ -58,7 +55,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<2>::new([1.0, 2.0]); @@ -75,7 +71,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let a = Vector::<3>::new([1.0, 2.0, 3.0]); @@ -98,7 +93,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<3>::new([1.0, 2.0, 3.0]); From 71908f25a790782d4a63a0579b6f4703ae59b5bd Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 15 Dec 2025 15:25:50 -0800 Subject: [PATCH 2/6] Fixed: Corrects nalgebra time display in benchmark table Corrects how zero values for nalgebra time are displayed in the benchmark table, preventing potential crashes due to division by zero and displaying "n/a" instead. Also pins uv version for reproducible CI and relaxes whitespace check in codacy config. --- .github/workflows/ci.yml | 2 +- .github/workflows/codacy.yml | 2 +- .gitignore | 2 +- scripts/criterion_dim_plot.py | 9 +++++++-- scripts/tests/test_criterion_dim_plot.py | 10 ++++++++++ 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e22976..cbd2e8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: if: matrix.os != 'windows-latest' uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 with: - version: "latest" + version: "0.9.17" # Pinned for reproducible CI - name: Install Node.js (for markdownlint and cspell) if: matrix.os != 'windows-latest' diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml index d131841..274942d 100644 --- a/.github/workflows/codacy.yml +++ b/.github/workflows/codacy.yml @@ -69,7 +69,7 @@ jobs: echo "::error::.codacy.yml not found in workspace copy ($config)" exit 1 fi - if ! grep -qE '^ bandit:' "$config"; then + if ! grep -qE '^[[:space:]]*bandit:' "$config"; then echo "::error::Bandit engine not configured in .codacy.yml; Python security scanning will be skipped." exit 1 fi diff --git a/.gitignore b/.gitignore index 5ab517c..43fa25c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,10 @@ .DS_Store # Python / uv -__pycache__/ **/__pycache__/ *.egg-info/ .venv/ +venv/ .ruff_cache/ .pytest_cache/ .mypy_cache/ diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index ef3b34a..32d850f 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -142,8 +142,13 @@ def _markdown_table(rows: list[tuple[int, float, float, float, float, float, flo ] for d, la, _la_lo, _la_hi, na, _na_lo, _na_hi in rows: - pct = ((na - la) / na) * 100.0 - lines.append(f"| {d} | {la:,.3f} | {na:,.3f} | {pct:+.1f}% |") + if na == 0.0: + pct_display = "n/a" + else: + pct = ((na - la) / na) * 100.0 + pct_display = f"{pct:+.1f}%" + + lines.append(f"| {d} | {la:,.3f} | {na:,.3f} | {pct_display} |") return "\n".join(lines) diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 62bfca1..917232d 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -32,6 +32,16 @@ def test_markdown_table_formats_values_and_pct() -> None: assert "| 64 | 1,000.000 | 900.000 | -11.1% |" in table +def test_markdown_table_handles_zero_nalgebra_time() -> None: + rows = [ + # nalgebra time of 0 indicates missing/corrupt data; ensure we don't crash. + (2, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ] + + table = criterion_dim_plot._markdown_table(rows, stat="median") + assert "| 2 | 10.000 | 0.000 | n/a |" in table + + def test_update_readme_table_replaces_only_between_markers(tmp_path: Path) -> None: marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") From 8224d865a782460eef0332971f64041b20a65ba9 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 15 Dec 2025 17:41:20 -0800 Subject: [PATCH 3/6] Changed: Refactors benchmarks to compare against other crates Renames and updates the benchmarking suite to compare against multiple linear algebra crates, including faer, in addition to nalgebra, providing a broader performance context. Adds faer implementations and benchmarks. Updates data and plots. --- Cargo.lock | 500 +++++++++++++++++- Cargo.toml | 3 +- README.md | 31 +- WARP.md | 2 +- benches/{vs_nalgebra.rs => vs_linalg.rs} | 152 +++++- cspell.json | 5 + .../bench/vs_linalg_lu_solve_median.csv | 9 + ...dian.svg => vs_linalg_lu_solve_median.svg} | 158 ++++-- .../bench/vs_nalgebra_lu_solve_median.csv | 9 - justfile | 18 +- scripts/README.md | 20 +- scripts/criterion_dim_plot.py | 81 +-- scripts/tests/test_criterion_dim_plot.py | 49 +- 13 files changed, 880 insertions(+), 157 deletions(-) rename benches/{vs_nalgebra.rs => vs_linalg.rs} (62%) create mode 100644 docs/assets/bench/vs_linalg_lu_solve_median.csv rename docs/assets/bench/{vs_nalgebra_lu_solve_median.svg => vs_linalg_lu_solve_median.svg} (73%) delete mode 100644 docs/assets/bench/vs_nalgebra_lu_solve_median.csv diff --git a/Cargo.lock b/Cargo.lock index ed0fe09..1714f4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,26 @@ name = "bytemuck" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cast" @@ -220,12 +240,86 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "defer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8" + +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "equator" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea" +dependencies = [ + "equator-macro 0.2.1", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro 0.4.2", +] + +[[package]] +name = "equator-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "errno" version = "0.3.14" @@ -236,6 +330,57 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "faer" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb922206162d9405f9fc059052b3f997bdc92745da7bfd620645f5092df20d1" +dependencies = [ + "bytemuck", + "dyn-stack", + "equator 0.4.2", + "faer-macros", + "faer-traits", + "gemm", + "generativity", + "libm", + "nano-gemm", + "num-complex", + "num-traits", + "private-gemm-x86", + "pulp", + "reborrow", +] + +[[package]] +name = "faer-macros" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc4b8cd876795d3b19ddfd59b03faa303c0b8adb9af6e188e81fc647c485bb9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "faer-traits" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b69235b5f54416286c485fb047f2f499fc935a4eee2caadf4757f3c94c7b62" +dependencies = [ + "bytemuck", + "dyn-stack", + "faer-macros", + "generativity", + "libm", + "num-complex", + "num-traits", + "pulp", + "qd", + "reborrow", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -254,6 +399,129 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "gemm" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" +dependencies = [ + "dyn-stack", + "gemm-c32", + "gemm-c64", + "gemm-common", + "gemm-f16", + "gemm-f32", + "gemm-f64", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp", + "raw-cpuid", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" +dependencies = [ + "dyn-stack", + "gemm-common", + "gemm-f32", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "generativity" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5881e4c3c2433fe4905bb19cfd2b5d49d4248274862b68c27c33d9ba4e13f9ec" + [[package]] name = "getrandom" version = "0.3.4" @@ -368,11 +636,36 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "interpol" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb58032ba748f4010d15912a1855a8a0b1ba9eaad3395b0c171c09b3b356ae50" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "itertools" version = "0.13.0" @@ -404,6 +697,7 @@ version = "0.1.0" dependencies = [ "approx", "criterion", + "faer", "nalgebra", "pastey", "proptest", @@ -415,6 +709,12 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -477,7 +777,77 @@ checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "nano-gemm" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5ba2bea1c00e53de11f6ab5bd0761ba87dc0045d63b0c87ee471d2d3061376" +dependencies = [ + "equator 0.2.2", + "nano-gemm-c32", + "nano-gemm-c64", + "nano-gemm-codegen", + "nano-gemm-core", + "nano-gemm-f32", + "nano-gemm-f64", + "num-complex", +] + +[[package]] +name = "nano-gemm-c32" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a40449e57a5713464c3a1208c4c3301c8d29ee1344711822cf022bc91373a91b" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-c64" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743a6e6211358fba85d1009616751e4107da86f4c95b24e684ce85f25c25b3bf" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-codegen" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963bf7c7110d55430169dc74c67096375491ed580cd2ef84842550ac72e781fa" + +[[package]] +name = "nano-gemm-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3fc4f83ae8861bad79dc3c016bd6b0220da5f9de302e07d3112d16efc24aa6" + +[[package]] +name = "nano-gemm-f32" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3681b7ce35658f79da94b7f62c60a005e29c373c7111ed070e3bf64546a8bb" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", +] + +[[package]] +name = "nano-gemm-f64" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1e619ed04d801809e1f63e61b669d380c4119e8b0cdd6ed184c6b111f046d8" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", ] [[package]] @@ -496,6 +866,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] @@ -526,6 +897,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -599,6 +981,18 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "private-gemm-x86" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0af8c3e5087969c323f667ccb4b789fa0954f5aa650550e38e81cf9108be21b5" +dependencies = [ + "defer", + "interpol", + "num_cpus", + "raw-cpuid", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -627,6 +1021,32 @@ dependencies = [ "unarray", ] +[[package]] +name = "pulp" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "reborrow", + "version_check", +] + +[[package]] +name = "qd" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff8bb755b6008c3b41bf8a0866c8dd4e1245a2f011ceaa22a13ee55c538493e2" +dependencies = [ + "bytemuck", + "libm", + "num-traits", + "pulp", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -686,6 +1106,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -712,6 +1141,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "regex" version = "1.12.2" @@ -796,6 +1231,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -823,7 +1264,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -858,6 +1299,17 @@ dependencies = [ "wide", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.111" @@ -869,6 +1321,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags", + "byteorder", + "enum-as-inner", + "libc", + "thiserror", + "walkdir", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -882,6 +1348,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -910,6 +1396,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -970,7 +1462,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "wasm-bindgen-shared", ] @@ -1072,5 +1564,5 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] diff --git a/Cargo.toml b/Cargo.toml index fbb54c7..9e99e90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,12 +17,13 @@ keywords = ["linear-algebra", "geometry", "const-generics"] [dev-dependencies] approx = "0.5.1" criterion = { version = "0.8.1", features = ["html_reports"] } +faer = { version = "0.23.2", default-features = false, features = ["std", "linalg"] } nalgebra = "0.34.1" pastey = "0.2.0" proptest = "1.9.0" [[bench]] -name = "vs_nalgebra" +name = "vs_linalg" harness = false [lints.rust] diff --git a/README.md b/README.md index cb26335..9bd607e 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ while keeping the API intentionally small and explicit. ## 🚫 Anti-goals -- Comprehensive: use [`nalgebra`](https://crates.io/crates/nalgebra) if you need a full-featured library - Bare-metal performance: see [`blas-src`](https://crates.io/crates/blas-src), [`lapack-src`](https://crates.io/crates/lapack-src), [`openblas-src`](https://crates.io/crates/openblas-src) +- Comprehensive: use [`nalgebra`](https://crates.io/crates/nalgebra) if you need a full-featured library +- Large matrices/dimensions with parallelism: use [`faer`](https://crates.io/crates/faer) if you need this ## 🔢 Scalar types @@ -112,25 +113,25 @@ just commit-check # lint + all tests + examples For the full set of developer commands, see `just --list` and `WARP.md`. -## 📊 Benchmarks (vs nalgebra) +## 📊 Benchmarks (vs nalgebra/faer) -![LU solve (factor + solve): median time vs dimension](docs/assets/bench/vs_nalgebra_lu_solve_median.svg) +![LU solve (factor + solve): median time vs dimension](docs/assets/bench/vs_linalg_lu_solve_median.svg) -Raw data: [docs/assets/bench/vs_nalgebra_lu_solve_median.csv](docs/assets/bench/vs_nalgebra_lu_solve_median.csv) +Raw data: [docs/assets/bench/vs_linalg_lu_solve_median.csv](docs/assets/bench/vs_linalg_lu_solve_median.csv) -Summary (median time; lower is better). “la-stack vs nalgebra” is the % time reduction relative to nalgebra (positive = la-stack faster): +Summary (median time; lower is better). The “la-stack vs nalgebra/faer” columns show the % time reduction relative to each baseline (positive = la-stack faster): -| D | la-stack median (ns) | nalgebra median (ns) | la-stack vs nalgebra | -|---:|--------------------:|--------------------:|---------------------:| -| 2 | 2.125 | 19.172 | +88.9% | -| 3 | 13.562 | 24.082 | +43.7% | -| 4 | 28.365 | 55.434 | +48.8% | -| 5 | 48.567 | 76.793 | +36.8% | -| 8 | 141.935 | 182.628 | +22.3% | -| 16 | 642.935 | 605.115 | -6.3% | -| 32 | 2,761.816 | 2,505.691 | -10.2% | -| 64 | 17,009.208 | 14,696.410 | -15.7% | +| D | la-stack median (ns) | nalgebra median (ns) | faer median (ns) | la-stack vs nalgebra | la-stack vs faer | +|---:|--------------------:|--------------------:|----------------:|---------------------:|----------------:| +| 2 | 2.065 | 18.375 | 160.418 | +88.8% | +98.7% | +| 3 | 13.457 | 23.377 | 198.440 | +42.4% | +93.2% | +| 4 | 27.750 | 54.267 | 228.744 | +48.9% | +87.9% | +| 5 | 46.317 | 73.840 | 291.623 | +37.3% | +84.1% | +| 8 | 138.183 | 177.982 | 389.006 | +22.4% | +64.5% | +| 16 | 629.427 | 591.505 | 893.672 | -6.4% | +29.6% | +| 32 | 2,688.216 | 2,503.157 | 2,908.436 | -7.4% | +7.6% | +| 64 | 16,771.962 | 14,860.016 | 12,485.424 | -12.9% | -34.3% | ## 📄 License diff --git a/WARP.md b/WARP.md index 334430f..06c5920 100644 --- a/WARP.md +++ b/WARP.md @@ -35,7 +35,7 @@ When making changes in this repo, prioritize (in order): - `src/lu.rs`: `Lu` factorization with partial pivoting (`solve_vec`, `det`) - A minimal `justfile` exists for common workflows (see `just --list`). - The public API re-exports these items from `src/lib.rs`. -- Dev-only benchmarks live in `benches/vs_nalgebra.rs` (Criterion + nalgebra comparison). +- Dev-only benchmarks live in `benches/vs_linalg.rs` (Criterion + nalgebra/faer comparison). ## Publishing note diff --git a/benches/vs_nalgebra.rs b/benches/vs_linalg.rs similarity index 62% rename from benches/vs_nalgebra.rs rename to benches/vs_linalg.rs index 70bd4a2..4493c91 100644 --- a/benches/vs_nalgebra.rs +++ b/benches/vs_linalg.rs @@ -1,16 +1,58 @@ -//! Benchmark comparison between la-stack and nalgebra. +//! Benchmark comparison between la-stack and other Rust linear algebra crates. //! //! Goal: like-for-like comparisons of the operations la-stack supports across several //! fixed dimensions. //! //! Notes: -//! - Determinant is benchmarked via LU on both sides (nalgebra uses closed-forms for 1×1/2×2/3×3). -//! - Matrix infinity norm is the maximum absolute row sum on both sides. +//! - Determinant is benchmarked via LU on all sides (nalgebra uses closed-forms for 1×1/2×2/3×3). +//! - Matrix infinity norm is the maximum absolute row sum on all sides. use criterion::Criterion; +use faer::linalg::solvers::Solve; +use faer::perm::PermRef; use pastey::paste; use std::hint::black_box; +fn faer_perm_sign(p: PermRef<'_, usize>) -> f64 { + // Sign(det(P)) for a permutation matrix P is +1 for even permutations, -1 for odd. + // Parity can be computed from the number of cycles: + // sign = (-1)^(n - cycles) + let (forward, _inverse) = p.arrays(); + let n = forward.len(); + + let mut seen = vec![false; n]; + let mut cycles = 0usize; + + for start in 0..n { + if seen[start] { + continue; + } + cycles += 1; + + let mut i = start; + while !seen[i] { + seen[i] = true; + i = forward[i]; + } + } + + if (n - cycles).is_multiple_of(2) { + 1.0 + } else { + -1.0 + } +} + +fn faer_det_from_partial_piv_lu(lu: &faer::linalg::solvers::PartialPivLu) -> f64 { + // For PA = LU with unit-lower L, det(A) = det(P) * det(U). + let u = lu.U(); + let mut det = 1.0; + for i in 0..u.nrows() { + det *= u[(i, i)]; + } + det * faer_perm_sign(lu.P()) +} + #[inline] #[allow(clippy::cast_precision_loss)] // D, r, c are small integers, precision loss is not an issue. fn matrix_entry(r: usize, c: usize) -> f64 { @@ -81,7 +123,7 @@ fn nalgebra_inf_norm(m: &nalgebra::SMatrix) -> f64 { max_row_sum } -macro_rules! gen_vs_nalgebra_benches_for_dim { +macro_rules! gen_vs_linalg_benches_for_dim { ($c:expr, $d:literal) => { paste! {{ // Isolate each dimension's inputs to keep types and captures clean. @@ -96,11 +138,17 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { let nv1 = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 0.0)); let nv2 = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 1.0)); + let fa = faer::Mat::::from_fn($d, $d, |r, c| matrix_entry::<$d>(r, c)); + let frhs = faer::Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); + let fv1 = faer::Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); + let fv2 = faer::Mat::::from_fn($d, 1, |i, _| vector_entry(i, 1.0)); + // Precompute LU once for solve-only / det-only benchmarks. let a_lu = a .lu(la_stack::DEFAULT_PIVOT_TOL) .expect("matrix should be non-singular"); let na_lu = na.clone().lu(); + let fa_lu = fa.partial_piv_lu(); let mut [] = ($c).benchmark_group(concat!("d", stringify!($d))); @@ -123,6 +171,14 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_det_via_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + let det = faer_det_from_partial_piv_lu(&lu); + black_box(det); + }); + }); + // === LU factorization === [].bench_function("la_stack_lu", |bencher| { bencher.iter(|| { @@ -140,6 +196,13 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + black_box(lu); + }); + }); + // === LU solve (factor + solve) === [].bench_function("la_stack_lu_solve", |bencher| { bencher.iter(|| { @@ -163,6 +226,14 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_lu_solve", |bencher| { + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + let x = lu.solve(black_box(&frhs)); + black_box(x); + }); + }); + // === Solve using a precomputed LU === [].bench_function("la_stack_solve_from_lu", |bencher| { bencher.iter(|| { @@ -182,6 +253,13 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = fa_lu.solve(black_box(&frhs)); + black_box(x); + }); + }); + // === Determinant from a precomputed LU === [].bench_function("la_stack_det_from_lu", |bencher| { bencher.iter(|| { @@ -197,6 +275,13 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_det_from_lu", |bencher| { + bencher.iter(|| { + let det = faer_det_from_partial_piv_lu(&fa_lu); + black_box(det); + }); + }); + // === Vector dot product === [].bench_function("la_stack_dot", |bencher| { bencher.iter(|| { @@ -212,6 +297,18 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_dot", |bencher| { + bencher.iter(|| { + let mut sum = 0.0; + let a = black_box(&fv1); + let b = black_box(&fv2); + for i in 0..$d { + sum += a[(i, 0)] * b[(i, 0)]; + } + black_box(sum); + }); + }); + // === Vector norm squared === [].bench_function("la_stack_norm2_sq", |bencher| { bencher.iter(|| { @@ -227,6 +324,18 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_norm2_sq", |bencher| { + bencher.iter(|| { + let mut sum = 0.0; + let v = black_box(&fv1); + for i in 0..$d { + let x = v[(i, 0)]; + sum += x * x; + } + black_box(sum); + }); + }); + // === Matrix infinity norm (max absolute row sum) === [].bench_function("la_stack_inf_norm", |bencher| { bencher.iter(|| { @@ -242,6 +351,25 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { }); }); + [].bench_function("faer_inf_norm", |bencher| { + bencher.iter(|| { + let m = black_box(&fa); + let mut max_row_sum = 0.0; + + for r in 0..$d { + let mut row_sum = 0.0; + for c in 0..$d { + row_sum += m[(r, c)].abs(); + } + if row_sum > max_row_sum { + max_row_sum = row_sum; + } + } + + black_box(max_row_sum); + }); + }); + [].finish(); } }} @@ -251,15 +379,15 @@ macro_rules! gen_vs_nalgebra_benches_for_dim { fn main() { let mut c = Criterion::default().configure_from_args(); - gen_vs_nalgebra_benches_for_dim!(&mut c, 2); - gen_vs_nalgebra_benches_for_dim!(&mut c, 3); - gen_vs_nalgebra_benches_for_dim!(&mut c, 4); - gen_vs_nalgebra_benches_for_dim!(&mut c, 5); + gen_vs_linalg_benches_for_dim!(&mut c, 2); + gen_vs_linalg_benches_for_dim!(&mut c, 3); + gen_vs_linalg_benches_for_dim!(&mut c, 4); + gen_vs_linalg_benches_for_dim!(&mut c, 5); - gen_vs_nalgebra_benches_for_dim!(&mut c, 8); - gen_vs_nalgebra_benches_for_dim!(&mut c, 16); - gen_vs_nalgebra_benches_for_dim!(&mut c, 32); - gen_vs_nalgebra_benches_for_dim!(&mut c, 64); + gen_vs_linalg_benches_for_dim!(&mut c, 8); + gen_vs_linalg_benches_for_dim!(&mut c, 16); + gen_vs_linalg_benches_for_dim!(&mut c, 32); + gen_vs_linalg_benches_for_dim!(&mut c, 64); c.final_summary(); } diff --git a/cspell.json b/cspell.json index db6a87d..c8d20b0 100644 --- a/cspell.json +++ b/cspell.json @@ -5,6 +5,7 @@ "words": [ "acgetchell", "blas", + "capsys", "Clippy", "clippy", "codacy", @@ -16,6 +17,8 @@ "f128", "f32", "f64", + "faer", + "frhs", "generics", "Getchell", "gnuplot", @@ -24,6 +27,7 @@ "keepends", "laerror", "lapack", + "linalg", "linespoints", "logscale", "lu", @@ -38,6 +42,7 @@ "nonfinite", "noplot", "nrhs", + "nrows", "openblas", "pastey", "patchlevel", diff --git a/docs/assets/bench/vs_linalg_lu_solve_median.csv b/docs/assets/bench/vs_linalg_lu_solve_median.csv new file mode 100644 index 0000000..cd2d751 --- /dev/null +++ b/docs/assets/bench/vs_linalg_lu_solve_median.csv @@ -0,0 +1,9 @@ +D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi +2,2.064783337073873,2.057689704502174,2.068888221867889,18.37497049638361,18.331137225709913,18.44905068359342,160.41828167015206,159.771806584937,161.23095742945685 +3,13.456732290936305,13.447517145467188,13.49236442840063,23.37662094350666,23.266710970814643,23.477406862396062,198.4402652520574,197.38808795625323,199.0284125157543 +4,27.75044852748178,27.599588184050052,27.785226320734317,54.266619121715074,54.15885008672231,54.36197439817226,228.74410991553628,227.56318439122288,230.055154703485 +5,46.31708492242545,46.21936017779927,46.502000712392494,73.83977618264294,73.57429654893484,74.07010889514254,291.6230709464669,290.6976886943525,292.8926041338858 +8,138.18343752426807,137.71388876876108,138.5654231809486,177.9820086523135,177.7122781945933,178.24448790485158,389.0063341187339,387.97176861931916,389.80891746278604 +16,629.4267672715603,626.9209242618742,638.6291503313327,591.5050977290701,590.798294714918,592.3790737673589,893.6719342969343,891.1105343477684,898.4784384384384 +32,2688.2164628623186,2684.227603602204,2691.7876890359166,2503.1570307509674,2500.668872475772,2506.5409929308526,2908.435890151515,2905.222288277013,2916.704963235294 +64,16771.9616225278,16754.37058346066,16817.485164835165,14860.015805946792,14773.547535211268,14956.477776495036,12485.423971036585,12471.988315217392,12501.272443181819 diff --git a/docs/assets/bench/vs_nalgebra_lu_solve_median.svg b/docs/assets/bench/vs_linalg_lu_solve_median.svg similarity index 73% rename from docs/assets/bench/vs_nalgebra_lu_solve_median.svg rename to docs/assets/bench/vs_linalg_lu_solve_median.svg index 0355730..21124e5 100644 --- a/docs/assets/bench/vs_nalgebra_lu_solve_median.svg +++ b/docs/assets/bench/vs_linalg_lu_solve_median.svg @@ -147,7 +147,7 @@ - + @@ -160,7 +160,7 @@ - + @@ -173,7 +173,7 @@ - + @@ -186,7 +186,7 @@ - + @@ -248,47 +248,47 @@ - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + @@ -302,51 +302,105 @@ - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + + + + gnuplot_plot_3 + + + faer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/bench/vs_nalgebra_lu_solve_median.csv b/docs/assets/bench/vs_nalgebra_lu_solve_median.csv deleted file mode 100644 index e2145de..0000000 --- a/docs/assets/bench/vs_nalgebra_lu_solve_median.csv +++ /dev/null @@ -1,9 +0,0 @@ -D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi -2,2.124532914367211,2.09971523996188,2.140164099914812,19.17217328467681,18.879067968718168,19.28482481777221 -3,13.561910796179443,13.524685753541656,13.620824746096288,24.082432810332314,23.94661978721645,24.233110558617877 -4,28.365434970029426,28.115956502260637,28.502018158281214,55.43414489536336,54.85631819804828,55.91912759333677 -5,48.5674565592533,47.968475042662924,48.699512778705056,76.79332103126086,75.63044466439385,77.34876828329485 -8,141.9353177287798,140.58909817870264,142.80253467051537,182.6280500376712,181.07586039158457,184.98799180091783 -16,642.9353974725984,635.2079326923077,653.6867862529627,605.1153810926868,600.7585890927475,613.4346656932975 -32,2761.8159597172203,2748.3301012712777,2778.877640467952,2505.6908196956647,2491.7890656874747,2545.825832615561 -64,17009.207815892314,16940.164226190478,17056.327922077922,14696.410081665515,14596.956030459347,14746.887774061686 diff --git a/justfile b/justfile index 958272e..90de2c5 100644 --- a/justfile +++ b/justfile @@ -46,30 +46,30 @@ action-lint: bench: cargo bench -# Bench the la-stack vs nalgebra comparison suite. -bench-vs-nalgebra filter="": +# Bench the la-stack vs nalgebra/faer comparison suite. +bench-vs-linalg filter="": #!/usr/bin/env bash set -euo pipefail filter="{{filter}}" if [ -n "$filter" ]; then - cargo bench --bench vs_nalgebra -- "$filter" + cargo bench --bench vs_linalg -- "$filter" else - cargo bench --bench vs_nalgebra + cargo bench --bench vs_linalg fi # Quick iteration (reduced runtime, no Criterion HTML). -bench-vs-nalgebra-quick filter="": +bench-vs-linalg-quick filter="": #!/usr/bin/env bash set -euo pipefail filter="{{filter}}" if [ -n "$filter" ]; then - cargo bench --bench vs_nalgebra -- "$filter" --quick --noplot + cargo bench --bench vs_linalg -- "$filter" --quick --noplot else - cargo bench --bench vs_nalgebra -- --quick --noplot + cargo bench --bench vs_linalg -- --quick --noplot fi # Plot: generate a single time-vs-dimension SVG from Criterion results. -plot-vs-nalgebra metric="lu_solve" stat="median" sample="new" log_y="false": python-sync +plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false": python-sync #!/usr/bin/env bash set -euo pipefail args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}") @@ -79,7 +79,7 @@ plot-vs-nalgebra metric="lu_solve" stat="median" sample="new" log_y="false": pyt uv run criterion-dim-plot "${args[@]}" # Plot + update the README benchmark table between BENCH_TABLE markers. -plot-vs-nalgebra-readme metric="lu_solve" stat="median" sample="new" log_y="false": python-sync +plot-vs-linalg-readme metric="lu_solve" stat="median" sample="new" log_y="false": python-sync #!/usr/bin/env bash set -euo pipefail args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}" --update-readme) diff --git a/scripts/README.md b/scripts/README.md index 43e53a6..4a76971 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,7 +14,7 @@ uv sync --group dev ## How to use it -### Plotting Criterion benchmarks (la-stack vs nalgebra) +### Plotting Criterion benchmarks (la-stack vs nalgebra/faer) The plotter reads Criterion output under: @@ -22,8 +22,8 @@ The plotter reads Criterion output under: And writes: -- `docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv` -- `docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg` +- `docs/assets/bench/vs_linalg_{metric}_{stat}.csv` +- `docs/assets/bench/vs_linalg_{metric}_{stat}.svg` To generate the single “time vs dimension” chart: @@ -33,29 +33,29 @@ By default, the benchmark suite runs for dimensions 2–5, 8, 16, 32, and 64. ```bash # full run (takes longer, better for README plots) -just bench-vs-nalgebra lu_solve +just bench-vs-linalg lu_solve # or quick run (fast sanity check; still produces estimates.json) -just bench-vs-nalgebra-quick lu_solve +just bench-vs-linalg-quick lu_solve ``` 2. Generate the chart (median or mean): ```bash # median (recommended) -just plot-vs-nalgebra lu_solve median new true +just plot-vs-linalg lu_solve median new true # median + update README's benchmark table (between BENCH_TABLE markers) -just plot-vs-nalgebra-readme lu_solve median new true +just plot-vs-linalg-readme lu_solve median new true # or mean -just plot-vs-nalgebra lu_solve mean new true +just plot-vs-linalg lu_solve mean new true ``` This writes: -- `docs/assets/bench/vs_nalgebra_lu_solve_median.csv` -- `docs/assets/bench/vs_nalgebra_lu_solve_median.svg` (requires `gnuplot`) +- `docs/assets/bench/vs_linalg_lu_solve_median.csv` +- `docs/assets/bench/vs_linalg_lu_solve_median.svg` (requires `gnuplot`) (For `stat=mean`, the filenames end in `_mean` instead of `_median`.) diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index 32d850f..ef081ee 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -5,11 +5,11 @@ target/criterion/d{D}/{benchmark}/{new|base}/estimates.json And writes: - docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv - docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg + docs/assets/bench/vs_linalg_{metric}_{stat}.csv + docs/assets/bench/vs_linalg_{metric}_{stat}.svg -This is intended to create a single, README-friendly plot comparing la-stack to nalgebra -across dimensions. +This is intended to create a single, README-friendly plot comparing la-stack to other +Rust linear algebra crates across dimensions. """ from __future__ import annotations @@ -29,6 +29,7 @@ class Metric: la_bench: str na_bench: str + fa_bench: str title: str @@ -46,42 +47,50 @@ class PlotRequest: "det_via_lu": Metric( la_bench="la_stack_det_via_lu", na_bench="nalgebra_det_via_lu", + fa_bench="faer_det_via_lu", title="Determinant via LU (factor + det)", ), "lu": Metric( la_bench="la_stack_lu", na_bench="nalgebra_lu", + fa_bench="faer_lu", title="LU factorization", ), "lu_solve": Metric( la_bench="la_stack_lu_solve", na_bench="nalgebra_lu_solve", + fa_bench="faer_lu_solve", title="LU solve (factor + solve)", ), "solve_from_lu": Metric( la_bench="la_stack_solve_from_lu", na_bench="nalgebra_solve_from_lu", + fa_bench="faer_solve_from_lu", title="Solve from precomputed LU", ), "det_from_lu": Metric( la_bench="la_stack_det_from_lu", na_bench="nalgebra_det_from_lu", + fa_bench="faer_det_from_lu", title="Determinant from precomputed LU", ), "dot": Metric( la_bench="la_stack_dot", na_bench="nalgebra_dot", + fa_bench="faer_dot", title="Vector dot product", ), # Different names between crates. "norm2_sq": Metric( la_bench="la_stack_norm2_sq", na_bench="nalgebra_norm_squared", + fa_bench="faer_norm2_sq", title="Vector squared 2-norm", ), "inf_norm": Metric( la_bench="la_stack_inf_norm", na_bench="nalgebra_inf_norm", + fa_bench="faer_inf_norm", title="Matrix infinity norm (max abs row sum)", ), } @@ -127,28 +136,32 @@ def _read_estimate(estimates_json: Path, stat: str) -> tuple[float, float, float return (point, lo, hi) -def _write_csv(out_csv: Path, rows: list[tuple[int, float, float, float, float, float, float]]) -> None: +def _write_csv(out_csv: Path, rows: list[Row]) -> None: out_csv.parent.mkdir(parents=True, exist_ok=True) with out_csv.open("w", encoding="utf-8") as f: - f.write("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi\n") - for d, la, la_lo, la_hi, na, na_lo, na_hi in rows: - f.write(f"{d},{la},{la_lo},{la_hi},{na},{na_lo},{na_hi}\n") + f.write("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi\n") + for d, la, la_lo, la_hi, na, na_lo, na_hi, fa, fa_lo, fa_hi in rows: + f.write(f"{d},{la},{la_lo},{la_hi},{na},{na_lo},{na_hi},{fa},{fa_lo},{fa_hi}\n") -def _markdown_table(rows: list[tuple[int, float, float, float, float, float, float]], stat: str) -> str: +def _pct_reduction(baseline: float, value: float) -> str: + """Percent time reduction relative to baseline (positive = value is faster).""" + if baseline == 0.0: + return "n/a" + pct = ((baseline - value) / baseline) * 100.0 + return f"{pct:+.1f}%" + + +def _markdown_table(rows: list[Row], stat: str) -> str: lines = [ - f"| D | la-stack {stat} (ns) | nalgebra {stat} (ns) | la-stack vs nalgebra |", - "|---:|--------------------:|--------------------:|---------------------:|", + f"| D | la-stack {stat} (ns) | nalgebra {stat} (ns) | faer {stat} (ns) | la-stack vs nalgebra | la-stack vs faer |", + "|---:|--------------------:|--------------------:|----------------:|---------------------:|----------------:|", ] - for d, la, _la_lo, _la_hi, na, _na_lo, _na_hi in rows: - if na == 0.0: - pct_display = "n/a" - else: - pct = ((na - la) / na) * 100.0 - pct_display = f"{pct:+.1f}%" - - lines.append(f"| {d} | {la:,.3f} | {na:,.3f} | {pct_display} |") + for d, la, _la_lo, _la_hi, na, _na_lo, _na_hi, fa, _fa_lo, _fa_hi in rows: + pct_vs_na = _pct_reduction(na, la) + pct_vs_fa = _pct_reduction(fa, la) + lines.append(f"| {d} | {la:,.3f} | {na:,.3f} | {fa:,.3f} | {pct_vs_na} | {pct_vs_fa} |") return "\n".join(lines) @@ -214,6 +227,7 @@ def _render_svg_with_gnuplot(req: PlotRequest) -> None: f"set xtics ({xtics})", "set style line 1 lc rgb '#1f77b4' lt 1 lw 2 pt 7 ps 1", "set style line 2 lc rgb '#ff7f0e' lt 1 lw 2 pt 5 ps 1", + "set style line 3 lc rgb '#2ca02c' lt 1 lw 2 pt 9 ps 1", "set style data linespoints", "set tics nomirror", "set border linewidth 1", @@ -226,7 +240,8 @@ def _render_svg_with_gnuplot(req: PlotRequest) -> None: [ "plot \\", f" {_gp_quote(str(req.csv_path))} using 1:2:3:4 with yerrorlines ls 1 title 'la-stack', \\", - f" {_gp_quote(str(req.csv_path))} using 1:5:6:7 with yerrorlines ls 2 title 'nalgebra'", + f" {_gp_quote(str(req.csv_path))} using 1:5:6:7 with yerrorlines ls 2 title 'nalgebra', \\", + f" {_gp_quote(str(req.csv_path))} using 1:8:9:10 with yerrorlines ls 3 title 'faer'", ] ) @@ -236,13 +251,13 @@ def _render_svg_with_gnuplot(req: PlotRequest) -> None: def _parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Plot Criterion time vs dimension for la-stack vs nalgebra.") + parser = argparse.ArgumentParser(description="Plot Criterion time vs dimension for la-stack vs nalgebra/faer.") parser.add_argument( "--metric", default="lu_solve", choices=sorted(METRICS.keys()), - help="Which vs_nalgebra metric to plot.", + help="Which vs_linalg metric to plot.", ) parser.add_argument( "--stat", @@ -264,12 +279,12 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument( "--out", default=None, - help="Output SVG path (default: docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg).", + help="Output SVG path (default: docs/assets/bench/vs_linalg_{metric}_{stat}.svg).", ) parser.add_argument( "--csv", default=None, - help="Output CSV path (default: docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv).", + help="Output CSV path (default: docs/assets/bench/vs_linalg_{metric}_{stat}.csv).", ) parser.add_argument( "--log-y", @@ -295,7 +310,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: return parser.parse_args(argv) -Row = tuple[int, float, float, float, float, float, float] +Row = tuple[int, float, float, float, float, float, float, float, float, float] def _resolve_under_root(root: Path, arg: str) -> Path: @@ -304,8 +319,8 @@ def _resolve_under_root(root: Path, arg: str) -> Path: def _resolve_output_paths(root: Path, metric: str, stat: str, out_svg: str | None, out_csv: str | None) -> tuple[Path, Path]: - svg = Path(out_svg) if out_svg is not None else Path(f"docs/assets/bench/vs_nalgebra_{metric}_{stat}.svg") - csv = Path(out_csv) if out_csv is not None else Path(f"docs/assets/bench/vs_nalgebra_{metric}_{stat}.csv") + svg = Path(out_svg) if out_svg is not None else Path(f"docs/assets/bench/vs_linalg_{metric}_{stat}.svg") + csv = Path(out_csv) if out_csv is not None else Path(f"docs/assets/bench/vs_linalg_{metric}_{stat}.csv") if not svg.is_absolute(): svg = root / svg @@ -323,14 +338,16 @@ def _collect_rows(criterion_dir: Path, dims: list[int], metric: Metric, stat: st group_dir = criterion_dir / f"d{d}" la_est = group_dir / metric.la_bench / sample / "estimates.json" na_est = group_dir / metric.na_bench / sample / "estimates.json" + fa_est = group_dir / metric.fa_bench / sample / "estimates.json" - if not la_est.exists() or not na_est.exists(): - skipped.append(f"d{d} (missing {metric.la_bench} or {metric.na_bench})") + if not la_est.exists() or not na_est.exists() or not fa_est.exists(): + skipped.append(f"d{d} (missing {metric.la_bench}, {metric.na_bench}, or {metric.fa_bench})") continue la, la_lo, la_hi = _read_estimate(la_est, stat) na, na_lo, na_hi = _read_estimate(na_est, stat) - rows.append((d, la, la_lo, la_hi, na, na_lo, na_hi)) + fa, fa_lo, fa_hi = _read_estimate(fa_est, stat) + rows.append((d, la, la_lo, la_hi, na, na_lo, na_hi, fa, fa_lo, fa_hi)) return (rows, skipped) @@ -363,7 +380,7 @@ def _maybe_render_plot(args: argparse.Namespace, req: PlotRequest, skipped: list try: _render_svg_with_gnuplot(req) - except FileNotFoundError as e: + except (FileNotFoundError, subprocess.CalledProcessError) as e: print(str(e), file=sys.stderr) print(f"Wrote CSV instead: {req.csv_path}", file=sys.stderr) return 1 @@ -388,7 +405,7 @@ def main(argv: list[str] | None = None) -> int: dims = _discover_dims(criterion_dir) if criterion_dir.exists() else [] if not dims: print( - f"No Criterion results found under {criterion_dir}.\n\nRun benchmarks first, e.g.:\n cargo bench --bench vs_nalgebra\n", + f"No Criterion results found under {criterion_dir}.\n\nRun benchmarks first, e.g.:\n cargo bench --bench vs_linalg\n", file=sys.stderr, ) return 2 diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 917232d..91d70a3 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -19,27 +19,51 @@ def test_readme_table_markers_are_stable() -> None: def test_markdown_table_formats_values_and_pct() -> None: rows = [ - # (D, la, la_lo, la_hi, na, na_lo, na_hi) - (2, 50.0, 0.0, 0.0, 100.0, 0.0, 0.0), # +50.0% - (64, 1_000.0, 0.0, 0.0, 900.0, 0.0, 0.0), # -11.1% + # (D, la, la_lo, la_hi, na, na_lo, na_hi, fa, fa_lo, fa_hi) + (2, 50.0, 0.0, 0.0, 100.0, 0.0, 0.0, 200.0, 0.0, 0.0), # +50.0% vs na, +75.0% vs fa + (64, 1_000.0, 0.0, 0.0, 900.0, 0.0, 0.0, 800.0, 0.0, 0.0), # -11.1% vs na, -25.0% vs fa ] table = criterion_dim_plot._markdown_table(rows, stat="median") - assert "| D | la-stack median (ns) | nalgebra median (ns) | la-stack vs nalgebra |" in table - assert "| 2 | 50.000 | 100.000 | +50.0% |" in table + assert "| D | la-stack median (ns) | nalgebra median (ns) | faer median (ns) | la-stack vs nalgebra | la-stack vs faer |" in table + assert "| 2 | 50.000 | 100.000 | 200.000 | +50.0% | +75.0% |" in table # thousand separator and sign - assert "| 64 | 1,000.000 | 900.000 | -11.1% |" in table + assert "| 64 | 1,000.000 | 900.000 | 800.000 | -11.1% | -25.0% |" in table def test_markdown_table_handles_zero_nalgebra_time() -> None: rows = [ # nalgebra time of 0 indicates missing/corrupt data; ensure we don't crash. - (2, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0), + (2, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, 0.0), ] table = criterion_dim_plot._markdown_table(rows, stat="median") - assert "| 2 | 10.000 | 0.000 | n/a |" in table + assert "| 2 | 10.000 | 0.000 | 100.000 | n/a | +90.0% |" in table + + +def test_maybe_render_plot_handles_gnuplot_failure(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Simulate gnuplot existing but failing to run (CalledProcessError). + def boom(_req: object) -> None: + raise criterion_dim_plot.subprocess.CalledProcessError(1, ["gnuplot"]) # type: ignore[arg-type] + + monkeypatch.setattr(criterion_dim_plot, "_render_svg_with_gnuplot", boom) + + args = type("Args", (), {"no_plot": False})() + req = criterion_dim_plot.PlotRequest( + csv_path=criterion_dim_plot.Path("out.csv"), + out_svg=criterion_dim_plot.Path("out.svg"), + title="t", + stat="median", + dims=(2,), + log_y=False, + ) + + rc = criterion_dim_plot._maybe_render_plot(args, req, skipped=[]) + assert rc == 1 + + captured = capsys.readouterr() + assert "Wrote CSV instead" in captured.err def test_update_readme_table_replaces_only_between_markers(tmp_path: Path) -> None: @@ -140,10 +164,11 @@ def write_estimates(path: Path, median: float) -> None: encoding="utf-8", ) - for d, la, na in [(2, 10.0, 20.0), (8, 100.0, 50.0)]: + for d, la, na, fa in [(2, 10.0, 20.0, 40.0), (8, 100.0, 50.0, 200.0)]: base = criterion_dir / f"d{d}" write_estimates(base / "la_stack_lu_solve" / "new" / "estimates.json", la) write_estimates(base / "nalgebra_lu_solve" / "new" / "estimates.json", na) + write_estimates(base / "faer_lu_solve" / "new" / "estimates.json", fa) readme = tmp_path / "README.md" marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") @@ -173,12 +198,12 @@ def write_estimates(path: Path, median: float) -> None: # CSV written csv_text = out_csv.read_text(encoding="utf-8") - assert csv_text.startswith("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi\n") + assert csv_text.startswith("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi\n") assert "2,10.0" in csv_text assert "8,100.0" in csv_text # README updated with computed table readme_text = readme.read_text(encoding="utf-8") assert "placeholder" not in readme_text - assert "| 2 | 10.000 | 20.000 | +50.0% |" in readme_text - assert "| 8 | 100.000 | 50.000 | -100.0% |" in readme_text + assert "| 2 | 10.000 | 20.000 | 40.000 | +50.0% | +75.0% |" in readme_text + assert "| 8 | 100.000 | 50.000 | 200.000 | -100.0% | +50.0% |" in readme_text From 732480afe68a14b36ccb546d34ad969ef5b35f41 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 15 Dec 2025 17:52:40 -0800 Subject: [PATCH 4/6] Fixed: Correctly escapes backslashes in gnuplot strings Fixes an issue where backslashes were not properly escaped when generating strings for gnuplot, leading to incorrect rendering. The gnuplot quoting function now escapes both backslashes and single quotes to ensure correct string interpretation by gnuplot. Refs: docs/benchmark --- scripts/criterion_dim_plot.py | 10 +++++----- scripts/tests/test_criterion_dim_plot.py | 7 +++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index ef081ee..58b2689 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -43,6 +43,9 @@ class PlotRequest: log_y: bool +Row = tuple[int, float, float, float, float, float, float, float, float, float] + + METRICS: Final[dict[str, Metric]] = { "det_via_lu": Metric( la_bench="la_stack_det_via_lu", @@ -201,8 +204,8 @@ def _update_readme_table(readme_path: Path, marker_begin: str, marker_end: str, def _gp_quote(s: str) -> str: - # gnuplot supports single-quoted strings; escape single quotes. - return "'" + s.replace("'", "\\'") + "'" + # gnuplot supports single-quoted strings; escape backslashes and single quotes. + return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'" def _render_svg_with_gnuplot(req: PlotRequest) -> None: @@ -310,9 +313,6 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: return parser.parse_args(argv) -Row = tuple[int, float, float, float, float, float, float, float, float, float] - - def _resolve_under_root(root: Path, arg: str) -> Path: path = Path(arg) return path if path.is_absolute() else root / path diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 91d70a3..c49ae16 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -42,6 +42,13 @@ def test_markdown_table_handles_zero_nalgebra_time() -> None: assert "| 2 | 10.000 | 0.000 | 100.000 | n/a | +90.0% |" in table +def test_gp_quote_escapes_backslashes_and_quotes() -> None: + assert criterion_dim_plot._gp_quote("plain") == "'plain'" + assert criterion_dim_plot._gp_quote("a'b") == "'a\\'b'" + assert criterion_dim_plot._gp_quote("a\\b") == "'a\\\\b'" + assert criterion_dim_plot._gp_quote("a\\'b") == "'a\\\\\\'b'" + + def test_maybe_render_plot_handles_gnuplot_failure(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: # Simulate gnuplot existing but failing to run (CalledProcessError). def boom(_req: object) -> None: From be24d033ec052fcd25b9598a067c774036675bc2 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 15 Dec 2025 18:25:04 -0800 Subject: [PATCH 5/6] Changed: Refactors criterion plotting script for clarity Refactors the criterion plotting script to improve readability and maintainability. It replaces the `Row` tuple with a `Row` dataclass for structured data handling and introduces `ReadmeMarkerError` for better error context when handling README markers. The script now uses named attributes, enhancing code clarity. (internal) --- scripts/criterion_dim_plot.py | 50 ++++++++++++++++++------ scripts/tests/test_criterion_dim_plot.py | 46 ++++++++++++++++++---- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index 58b2689..9c1e93a 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -43,7 +43,22 @@ class PlotRequest: log_y: bool -Row = tuple[int, float, float, float, float, float, float, float, float, float] +@dataclass(frozen=True, slots=True) +class Row: + dim: int + la_time: float + la_lo: float + la_hi: float + na_time: float + na_lo: float + na_hi: float + fa_time: float + fa_lo: float + fa_hi: float + + +class ReadmeMarkerError(ValueError): + """Raised when README markers are missing, duplicated, or out of order.""" METRICS: Final[dict[str, Metric]] = { @@ -143,8 +158,8 @@ def _write_csv(out_csv: Path, rows: list[Row]) -> None: out_csv.parent.mkdir(parents=True, exist_ok=True) with out_csv.open("w", encoding="utf-8") as f: f.write("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi\n") - for d, la, la_lo, la_hi, na, na_lo, na_hi, fa, fa_lo, fa_hi in rows: - f.write(f"{d},{la},{la_lo},{la_hi},{na},{na_lo},{na_hi},{fa},{fa_lo},{fa_hi}\n") + for row in rows: + f.write(f"{row.dim},{row.la_time},{row.la_lo},{row.la_hi},{row.na_time},{row.na_lo},{row.na_hi},{row.fa_time},{row.fa_lo},{row.fa_hi}\n") def _pct_reduction(baseline: float, value: float) -> str: @@ -161,10 +176,10 @@ def _markdown_table(rows: list[Row], stat: str) -> str: "|---:|--------------------:|--------------------:|----------------:|---------------------:|----------------:|", ] - for d, la, _la_lo, _la_hi, na, _na_lo, _na_hi, fa, _fa_lo, _fa_hi in rows: - pct_vs_na = _pct_reduction(na, la) - pct_vs_fa = _pct_reduction(fa, la) - lines.append(f"| {d} | {la:,.3f} | {na:,.3f} | {fa:,.3f} | {pct_vs_na} | {pct_vs_fa} |") + for row in rows: + pct_vs_na = _pct_reduction(row.na_time, row.la_time) + pct_vs_fa = _pct_reduction(row.fa_time, row.la_time) + lines.append(f"| {row.dim} | {row.la_time:,.3f} | {row.na_time:,.3f} | {row.fa_time:,.3f} | {pct_vs_na} | {pct_vs_fa} |") return "\n".join(lines) @@ -181,13 +196,13 @@ def _update_readme_table(readme_path: Path, marker_begin: str, marker_end: str, end_indices = [i for i, line in enumerate(lines) if line.strip() == marker_end] if len(begin_indices) != 1 or len(end_indices) != 1: - raise ValueError(f"README markers not found or not unique. Expected exactly one of each:\n {marker_begin}\n {marker_end}\n") + raise ReadmeMarkerError(f"README markers not found or not unique. Expected exactly one of each:\n {marker_begin}\n {marker_end}\n") begin_idx = begin_indices[0] end_idx = end_indices[0] if begin_idx >= end_idx: msg = "README markers are out of order." - raise ValueError(msg) + raise ReadmeMarkerError(msg) table_lines = [line + "\n" for line in table_md.strip("\n").splitlines()] new_lines = [ @@ -347,7 +362,20 @@ def _collect_rows(criterion_dir: Path, dims: list[int], metric: Metric, stat: st la, la_lo, la_hi = _read_estimate(la_est, stat) na, na_lo, na_hi = _read_estimate(na_est, stat) fa, fa_lo, fa_hi = _read_estimate(fa_est, stat) - rows.append((d, la, la_lo, la_hi, na, na_lo, na_hi, fa, fa_lo, fa_hi)) + rows.append( + Row( + dim=d, + la_time=la, + la_lo=la_lo, + la_hi=la_hi, + na_time=na, + na_lo=na_lo, + na_hi=na_hi, + fa_time=fa, + fa_lo=fa_lo, + fa_hi=fa_hi, + ) + ) return (rows, skipped) @@ -431,7 +459,7 @@ def main(argv: list[str] | None = None) -> int: if rc != 0: return rc - dims_present = [d for (d, *_rest) in rows] + dims_present = [row.dim for row in rows] title = f"{metric.title}: {args.stat} time vs dimension" req = PlotRequest( diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index c49ae16..f29c79b 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -19,9 +19,30 @@ def test_readme_table_markers_are_stable() -> None: def test_markdown_table_formats_values_and_pct() -> None: rows = [ - # (D, la, la_lo, la_hi, na, na_lo, na_hi, fa, fa_lo, fa_hi) - (2, 50.0, 0.0, 0.0, 100.0, 0.0, 0.0, 200.0, 0.0, 0.0), # +50.0% vs na, +75.0% vs fa - (64, 1_000.0, 0.0, 0.0, 900.0, 0.0, 0.0, 800.0, 0.0, 0.0), # -11.1% vs na, -25.0% vs fa + criterion_dim_plot.Row( + dim=2, + la_time=50.0, + la_lo=0.0, + la_hi=0.0, + na_time=100.0, + na_lo=0.0, + na_hi=0.0, + fa_time=200.0, + fa_lo=0.0, + fa_hi=0.0, + ), # +50.0% vs na, +75.0% vs fa + criterion_dim_plot.Row( + dim=64, + la_time=1_000.0, + la_lo=0.0, + la_hi=0.0, + na_time=900.0, + na_lo=0.0, + na_hi=0.0, + fa_time=800.0, + fa_lo=0.0, + fa_hi=0.0, + ), # -11.1% vs na, -25.0% vs fa ] table = criterion_dim_plot._markdown_table(rows, stat="median") @@ -35,7 +56,18 @@ def test_markdown_table_formats_values_and_pct() -> None: def test_markdown_table_handles_zero_nalgebra_time() -> None: rows = [ # nalgebra time of 0 indicates missing/corrupt data; ensure we don't crash. - (2, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, 0.0), + criterion_dim_plot.Row( + dim=2, + la_time=10.0, + la_lo=0.0, + la_hi=0.0, + na_time=0.0, + na_lo=0.0, + na_hi=0.0, + fa_time=100.0, + fa_lo=0.0, + fa_hi=0.0, + ), ] table = criterion_dim_plot._markdown_table(rows, stat="median") @@ -114,7 +146,7 @@ def test_update_readme_table_errors_on_missing_markers(tmp_path: Path) -> None: readme = tmp_path / "README.md" readme.write_text("# Title\n", encoding="utf-8") - with pytest.raises(ValueError, match=r"README markers not found"): + with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"README markers not found"): criterion_dim_plot._update_readme_table( readme, "", @@ -129,7 +161,7 @@ def test_update_readme_table_errors_on_out_of_order_markers(tmp_path: Path) -> N readme = tmp_path / "README.md" readme.write_text("\n".join([marker_end, marker_begin, ""]), encoding="utf-8") - with pytest.raises(ValueError, match=r"out of order"): + with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"out of order"): criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") @@ -149,7 +181,7 @@ def test_update_readme_table_errors_on_non_unique_markers(tmp_path: Path) -> Non encoding="utf-8", ) - with pytest.raises(ValueError, match=r"not found or not unique"): + with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"not found or not unique"): criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") From cd6873f74aaaff4cd272223ad7f0b41f889f5870 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 15 Dec 2025 18:40:08 -0800 Subject: [PATCH 6/6] Fixed: Improves error handling for README table updates Refactors README table update error handling for better clarity and specificity. Introduces dedicated exception classes for missing/non-unique markers and out-of-order markers, providing more descriptive error messages. --- scripts/criterion_dim_plot.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index 9c1e93a..2a63d42 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -58,7 +58,15 @@ class Row: class ReadmeMarkerError(ValueError): - """Raised when README markers are missing, duplicated, or out of order.""" + """Base error for invalid README BENCH_TABLE markers.""" + + +class MarkerNotFoundError(ReadmeMarkerError): + """Raised when README markers are missing or not unique.""" + + +class MarkerOrderError(ReadmeMarkerError): + """Raised when README markers are out of order.""" METRICS: Final[dict[str, Metric]] = { @@ -196,13 +204,14 @@ def _update_readme_table(readme_path: Path, marker_begin: str, marker_end: str, end_indices = [i for i, line in enumerate(lines) if line.strip() == marker_end] if len(begin_indices) != 1 or len(end_indices) != 1: - raise ReadmeMarkerError(f"README markers not found or not unique. Expected exactly one of each:\n {marker_begin}\n {marker_end}\n") + msg = f"README markers not found or not unique (begin={len(begin_indices)}, end={len(end_indices)})." + raise MarkerNotFoundError(msg) begin_idx = begin_indices[0] end_idx = end_indices[0] if begin_idx >= end_idx: msg = "README markers are out of order." - raise ReadmeMarkerError(msg) + raise MarkerOrderError(msg) table_lines = [line + "\n" for line in table_md.strip("\n").splitlines()] new_lines = [