Skip to content

Commit 150678a

Browse files
authored
Merge pull request #97 from acgetchell/perf/80-exact-bench-adversarial
test: add adversarial-input coverage for exact arithmetic (#80)
2 parents 2d49d73 + 1e0648d commit 150678a

10 files changed

Lines changed: 687 additions & 36 deletions

File tree

.github/workflows/benchmarks.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,11 @@ jobs:
114114
115115
if [ -d target/criterion/exact_d2/det/main ]; then
116116
echo "::notice::Baseline found — comparing against main"
117+
# --baseline-lenient rather than --baseline: benches added on the
118+
# PR branch that don't yet exist in the main baseline get a
119+
# "no baseline data" notice instead of aborting the whole run.
117120
cargo bench --features bench,exact --bench exact \
118-
-- --baseline main 2>&1 | tee bench-output.txt
121+
-- --baseline-lenient main 2>&1 | tee bench-output.txt
119122
else
120123
echo "::notice::No baseline found — running without comparison"
121124
cargo bench --features bench,exact --bench exact \

AGENTS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ When creating or updating issues:
221221
222222
- `exact` — enables exact arithmetic methods via `BigRational`:
223223
`det_exact()`, `det_exact_f64()`, `det_sign_exact()`, `solve_exact()`, and `solve_exact_f64()`.
224-
Also re-exports `BigRational` from the crate root and prelude.
224+
Re-exports `BigInt`, `BigRational`, and the commonly needed `num-traits`
225+
items (`FromPrimitive`, `ToPrimitive`, and `Signed`) from the crate root and prelude
226+
(so consumers get usable `from_f64` / `to_f64` / `is_positive` etc. without adding
227+
`num-bigint` / `num-rational` / `num-traits` as their own deps).
225228
Gates `src/exact.rs`, additional tests, and the `exact_det_3x3`/`exact_sign_3x3`/`exact_solve_3x3` examples.
226229
Clippy, doc builds, and test commands have dedicated `--features exact` variants.
227230
@@ -245,7 +248,8 @@ When creating or updating issues:
245248
- The public API re-exports these items from `src/lib.rs`.
246249
- The `justfile` defines all dev workflows (see `just --list`).
247250
- Dev-only benchmarks live in `benches/vs_linalg.rs` (Criterion + nalgebra/faer comparison)
248-
and `benches/exact.rs` (exact arithmetic across D=2–5).
251+
and `benches/exact.rs` (exact arithmetic across D=2–5, plus adversarial-input groups
252+
`exact_near_singular_3x3`, `exact_large_entries_3x3`, `exact_hilbert_4x4`, `exact_hilbert_5x5`).
249253
- Python scripts under `scripts/`:
250254
- `bench_compare.py`: exact-arithmetic benchmark comparison across releases (generates `docs/PERFORMANCE.md`)
251255
- `criterion_dim_plot.py`: benchmark plotting (CSV + SVG + README table update)

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,13 @@ assert!((x[0] - 1.0).abs() <= f64::EPSILON);
190190
assert!((x[1] - 2.0).abs() <= f64::EPSILON);
191191
```
192192

193-
`BigRational` is re-exported from the crate root and prelude when the `exact`
194-
feature is enabled, so consumers don't need to depend on `num-rational` directly.
193+
With the `exact` feature enabled, `BigInt` and `BigRational` are re-exported
194+
from the crate root and prelude, alongside the most commonly needed
195+
`num-traits` items (`FromPrimitive`, `ToPrimitive`, `Signed`). This lets
196+
consumers construct exact values (`BigRational::from_f64`, `from_i64`), query
197+
sign (`is_positive` / `is_negative`), and convert back to `f64` (`to_f64`)
198+
with a single `use la_stack::prelude::*;` — no need to add `num-bigint`,
199+
`num-rational`, or `num-traits` to their own `Cargo.toml`.
195200

196201
For `det_sign_exact()`, D ≤ 4 matrices use a fast f64 filter (error-bounded
197202
`det_direct()`) that resolves the sign without allocating. Only near-degenerate

benches/exact.rs

Lines changed: 166 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
//! Benchmarks for exact arithmetic operations.
22
//!
33
//! These benchmarks measure the performance of the `exact` feature's
4-
//! arbitrary-precision methods across dimensions D=2..5 (the primary
5-
//! target for geometric predicates).
4+
//! arbitrary-precision methods. They are organised into two classes:
5+
//!
6+
//! 1. **General-case benches** (`exact_d{2..5}`) — a single
7+
//! well-conditioned diagonally-dominant matrix per dimension. These
8+
//! measure typical-case performance and track regressions against a
9+
//! reproducible input.
10+
//! 2. **Adversarial / extreme-input benches** — matrices chosen to
11+
//! stress specific corners of the exact-arithmetic pipeline:
12+
//! near-singularity (forces the Bareiss fallback), large f64 entries
13+
//! (stresses intermediate `BigInt` growth), and Hilbert-style
14+
//! ill-conditioning (wide range of `(mantissa, exponent)` pairs in
15+
//! the `f64_decompose → BigInt` path). These measure tail behaviour
16+
//! that fixed well-conditioned inputs miss and provide stronger
17+
//! empirical evidence for `docs/PERFORMANCE.md`.
618
7-
use criterion::Criterion;
19+
use criterion::{BenchmarkGroup, Criterion, measurement::WallTime};
820
use la_stack::{Matrix, Vector};
921
use pastey::paste;
1022
use std::hint::black_box;
@@ -47,8 +59,13 @@ fn make_vector_array<const D: usize>() -> [f64; D] {
4759
}
4860

4961
/// Near-singular matrix: base singular matrix + tiny perturbation.
50-
/// This forces the exact Bareiss fallback in `det_sign_exact` (the fast
51-
/// f64 filter cannot resolve the sign).
62+
///
63+
/// The base `[[1,2,3],[4,5,6],[7,8,9]]` is exactly singular; adding
64+
/// `2^-50` to entry (0,0) makes `det = -3 × 2^-50 ≠ 0`. The f64 filter
65+
/// in `det_sign_exact` cannot resolve this sign, so Bareiss is forced;
66+
/// `solve_exact` is the primary use case for near-degenerate inputs
67+
/// (exact circumcenter etc.) and exercises the largest intermediate
68+
/// `BigInt` values in the hybrid solve.
5269
#[inline]
5370
fn near_singular_3x3() -> Matrix<3> {
5471
let perturbation = f64::from_bits(0x3CD0_0000_0000_0000); // 2^-50
@@ -59,6 +76,97 @@ fn near_singular_3x3() -> Matrix<3> {
5976
])
6077
}
6178

79+
/// Large-entry 3×3: strictly diagonally-dominant matrix with diagonal
80+
/// entries near `f64::MAX / 2` and ones elsewhere.
81+
///
82+
/// Each big entry decomposes into a 53-bit mantissa with exponent `~970`;
83+
/// the unit off-diagonals have exponent `0`, so the shared `e_min = 0`
84+
/// shift in `component_to_bigint` produces `BigInt`s of `~1023` bits for
85+
/// the diagonal and small integers elsewhere. Bareiss fraction-free
86+
/// updates then multiply these together, stressing the big-integer
87+
/// multiply and allocator along the full `O(D³)` elimination phase. The
88+
/// matrix is non-singular (det ≈ `big³`) so both `det_*` and `solve_*`
89+
/// paths complete.
90+
#[inline]
91+
fn large_entries_3x3() -> Matrix<3> {
92+
let big = f64::MAX / 2.0;
93+
Matrix::<3>::from_rows([[big, 1.0, 1.0], [1.0, big, 1.0], [1.0, 1.0, big]])
94+
}
95+
96+
/// Hilbert matrix `H[i][j] = 1 / (i + j + 1)`.
97+
///
98+
/// Most entries (`1/3`, `1/5`, `1/6`, `1/7`, …) are non-terminating in
99+
/// binary, so every cell has a distinct 53-bit mantissa and a small
100+
/// negative exponent. `f64_decompose` therefore produces a wide mix of
101+
/// `(mantissa, exponent)` pairs with no shared power-of-two factors,
102+
/// and the scaling shift to the common `e_min` yields `BigInt` values
103+
/// of varied bit-lengths — a different kind of adversarial input from
104+
/// the large-entries case. Hilbert matrices are also classically
105+
/// ill-conditioned (condition number grows exponentially with D), so
106+
/// they are a realistic stand-in for the near-degenerate geometric
107+
/// predicate inputs that motivate exact arithmetic.
108+
#[inline]
109+
#[allow(clippy::cast_precision_loss)]
110+
fn hilbert<const D: usize>() -> Matrix<D> {
111+
let mut rows = [[0.0; D]; D];
112+
let mut r = 0;
113+
while r < D {
114+
let mut c = 0;
115+
while c < D {
116+
rows[r][c] = 1.0 / ((r + c + 1) as f64);
117+
c += 1;
118+
}
119+
r += 1;
120+
}
121+
Matrix::<D>::from_rows(rows)
122+
}
123+
124+
/// Populate a Criterion group with the four headline exact-arithmetic
125+
/// benches on a single `(matrix, rhs)` pair: `det_sign_exact`,
126+
/// `det_exact`, `solve_exact`, `solve_exact_f64`.
127+
///
128+
/// Used by every adversarial-input group so each one measures the same
129+
/// operations, making the resulting tables directly comparable.
130+
fn bench_extreme_group<const D: usize>(
131+
group: &mut BenchmarkGroup<'_, WallTime>,
132+
m: Matrix<D>,
133+
rhs: Vector<D>,
134+
) {
135+
group.bench_function("det_sign_exact", |bencher| {
136+
bencher.iter(|| {
137+
let sign = black_box(m)
138+
.det_sign_exact()
139+
.expect("finite matrix entries");
140+
black_box(sign);
141+
});
142+
});
143+
144+
group.bench_function("det_exact", |bencher| {
145+
bencher.iter(|| {
146+
let det = black_box(m).det_exact().expect("finite matrix entries");
147+
black_box(det);
148+
});
149+
});
150+
151+
group.bench_function("solve_exact", |bencher| {
152+
bencher.iter(|| {
153+
let x = black_box(m)
154+
.solve_exact(black_box(rhs))
155+
.expect("non-singular matrix with finite entries");
156+
let _ = black_box(x);
157+
});
158+
});
159+
160+
group.bench_function("solve_exact_f64", |bencher| {
161+
bencher.iter(|| {
162+
let x = black_box(m)
163+
.solve_exact_f64(black_box(rhs))
164+
.expect("solution representable in f64");
165+
let _ = black_box(x);
166+
});
167+
});
168+
}
169+
62170
macro_rules! gen_exact_benches_for_dim {
63171
($c:expr, $d:literal) => {
64172
paste! {{
@@ -72,7 +180,7 @@ macro_rules! gen_exact_benches_for_dim {
72180
bencher.iter(|| {
73181
let det = black_box(a)
74182
.det(la_stack::DEFAULT_PIVOT_TOL)
75-
.expect("should not fail");
183+
.expect("diagonally dominant matrix is non-singular");
76184
black_box(det);
77185
});
78186
});
@@ -87,39 +195,47 @@ macro_rules! gen_exact_benches_for_dim {
87195
// === det_exact (BigRational result) ===
88196
[<group_d $d>].bench_function("det_exact", |bencher| {
89197
bencher.iter(|| {
90-
let det = black_box(a).det_exact().expect("should not fail");
198+
let det = black_box(a).det_exact().expect("finite matrix entries");
91199
black_box(det);
92200
});
93201
});
94202

95203
// === det_exact_f64 (exact → f64) ===
96204
[<group_d $d>].bench_function("det_exact_f64", |bencher| {
97205
bencher.iter(|| {
98-
let det = black_box(a).det_exact_f64().expect("should not fail");
206+
let det = black_box(a)
207+
.det_exact_f64()
208+
.expect("det representable in f64");
99209
black_box(det);
100210
});
101211
});
102212

103213
// === det_sign_exact (adaptive: fast filter + exact fallback) ===
104214
[<group_d $d>].bench_function("det_sign_exact", |bencher| {
105215
bencher.iter(|| {
106-
let sign = black_box(a).det_sign_exact().expect("should not fail");
216+
let sign = black_box(a)
217+
.det_sign_exact()
218+
.expect("finite matrix entries");
107219
black_box(sign);
108220
});
109221
});
110222

111223
// === solve_exact (BigRational result) ===
112224
[<group_d $d>].bench_function("solve_exact", |bencher| {
113225
bencher.iter(|| {
114-
let x = black_box(a).solve_exact(black_box(rhs)).expect("should not fail");
226+
let x = black_box(a)
227+
.solve_exact(black_box(rhs))
228+
.expect("diagonally dominant matrix is non-singular");
115229
black_box(x);
116230
});
117231
});
118232

119233
// === solve_exact_f64 (exact → f64) ===
120234
[<group_d $d>].bench_function("solve_exact_f64", |bencher| {
121235
bencher.iter(|| {
122-
let x = black_box(a).solve_exact_f64(black_box(rhs)).expect("should not fail");
236+
let x = black_box(a)
237+
.solve_exact_f64(black_box(rhs))
238+
.expect("solution representable in f64");
123239
black_box(x);
124240
});
125241
});
@@ -140,25 +256,50 @@ fn main() {
140256
gen_exact_benches_for_dim!(&mut c, 5);
141257
}
142258

143-
// Near-singular 3×3: forces Bareiss fallback in det_sign_exact.
259+
// === Adversarial / extreme-input groups ===
260+
//
261+
// Each group runs the same four exact-arithmetic benches
262+
// (`det_sign_exact`, `det_exact`, `solve_exact`, `solve_exact_f64`)
263+
// via `bench_extreme_group`, so the resulting tables are directly
264+
// comparable across input classes.
265+
266+
// Near-singular 3×3: forces Bareiss fallback in det_sign_exact and
267+
// exercises the largest intermediate BigInt values in solve_exact
268+
// (the primary motivating use case for exact solve).
144269
{
145-
let m = near_singular_3x3();
146270
let mut group = c.benchmark_group("exact_near_singular_3x3");
271+
bench_extreme_group(
272+
&mut group,
273+
near_singular_3x3(),
274+
Vector::<3>::new([1.0, 2.0, 3.0]),
275+
);
276+
group.finish();
277+
}
147278

148-
group.bench_function("det_sign_exact", |bencher| {
149-
bencher.iter(|| {
150-
let sign = black_box(m).det_sign_exact().expect("should not fail");
151-
black_box(sign);
152-
});
153-
});
279+
// Large-entry 3×3: diagonal entries near `f64::MAX / 2` stress
280+
// BigInt growth during Bareiss forward elimination.
281+
{
282+
let mut group = c.benchmark_group("exact_large_entries_3x3");
283+
bench_extreme_group(
284+
&mut group,
285+
large_entries_3x3(),
286+
Vector::<3>::new([1.0, 1.0, 1.0]),
287+
);
288+
group.finish();
289+
}
154290

155-
group.bench_function("det_exact", |bencher| {
156-
bencher.iter(|| {
157-
let det = black_box(m).det_exact().expect("should not fail");
158-
black_box(det);
159-
});
160-
});
291+
// Hilbert 4×4 and 5×5: classically ill-conditioned matrices whose
292+
// entries span many orders of magnitude in `(mantissa, exponent)`
293+
// space, exercising the f64 → BigInt scaling path.
294+
{
295+
let mut group = c.benchmark_group("exact_hilbert_4x4");
296+
bench_extreme_group(&mut group, hilbert::<4>(), Vector::<4>::new([1.0; 4]));
297+
group.finish();
298+
}
161299

300+
{
301+
let mut group = c.benchmark_group("exact_hilbert_5x5");
302+
bench_extreme_group(&mut group, hilbert::<5>(), Vector::<5>::new([1.0; 5]));
162303
group.finish();
163304
}
164305

docs/BENCHMARKING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ la-stack has two Criterion benchmark suites:
1414
(`det_exact`, `solve_exact`, `det_sign_exact`, etc.) alongside f64
1515
baselines (`det`, `det_direct`) across D=2–5. Use this to understand
1616
the cost of exact arithmetic and track optimization progress.
17+
In addition to the per-dimension groups (`exact_d{2..5}`), the suite
18+
includes four adversarial-input groups designed to stress specific
19+
corners of the pipeline:
20+
- `exact_near_singular_3x3` — a 2^-50 perturbation of a singular base
21+
matrix; forces the Bareiss fallback in `det_sign_exact` and
22+
exercises the largest intermediate `BigInt` values in `solve_exact`.
23+
- `exact_large_entries_3x3` — diagonal entries near `f64::MAX / 2`
24+
stress `BigInt` growth during Bareiss forward elimination.
25+
- `exact_hilbert_4x4` / `exact_hilbert_5x5` — classically
26+
ill-conditioned matrices whose non-terminating-in-binary entries
27+
stress the `f64_decompose → BigInt` scaling path.
28+
Each adversarial group runs the same four benches (`det_sign_exact`,
29+
`det_exact`, `solve_exact`, `solve_exact_f64`) so the resulting tables
30+
are directly comparable across input classes.
1731

1832
## Quick reference
1933

scripts/bench_compare.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,22 @@
3838
# ---------------------------------------------------------------------------
3939

4040
# Groups and the benchmarks within each group that we track.
41+
#
42+
# Mirrors the structure of `benches/exact.rs`: general-case per-dimension
43+
# groups (`exact_d{2..5}`) plus adversarial/extreme-input groups that
44+
# share a fixed four-bench layout (`det_sign_exact`, `det_exact`,
45+
# `solve_exact`, `solve_exact_f64`).
46+
_EXTREME_BENCHES: list[str] = ["det_sign_exact", "det_exact", "solve_exact", "solve_exact_f64"]
47+
4148
EXACT_GROUPS: dict[str, list[str]] = {
4249
"exact_d2": ["det", "det_direct", "det_exact", "det_exact_f64", "det_sign_exact", "solve_exact", "solve_exact_f64"],
4350
"exact_d3": ["det", "det_direct", "det_exact", "det_exact_f64", "det_sign_exact", "solve_exact", "solve_exact_f64"],
4451
"exact_d4": ["det", "det_direct", "det_exact", "det_exact_f64", "det_sign_exact", "solve_exact", "solve_exact_f64"],
4552
"exact_d5": ["det", "det_direct", "det_exact", "det_exact_f64", "det_sign_exact", "solve_exact", "solve_exact_f64"],
46-
"exact_near_singular_3x3": ["det_sign_exact", "det_exact"],
53+
"exact_near_singular_3x3": _EXTREME_BENCHES,
54+
"exact_large_entries_3x3": _EXTREME_BENCHES,
55+
"exact_hilbert_4x4": _EXTREME_BENCHES,
56+
"exact_hilbert_5x5": _EXTREME_BENCHES,
4757
}
4858

4959

@@ -197,11 +207,16 @@ def _group_by_group(items: list[_T]) -> dict[str, list[_T]]:
197207

198208
def _group_heading(group: str) -> str:
199209
"""Turn a Criterion group name into a readable heading."""
200-
# exact_d3 -> "D=3", exact_near_singular_3x3 -> "Near-singular 3x3"
210+
# exact_d3 -> "D=3", exact_near_singular_3x3 -> "Near-singular 3x3",
211+
# exact_hilbert_4x4 -> "Hilbert 4x4", etc.
201212
if group.startswith("exact_d"):
202213
return f"D={group.removeprefix('exact_d')}"
203214
if group == "exact_near_singular_3x3":
204215
return "Near-singular 3x3"
216+
if group == "exact_large_entries_3x3":
217+
return "Large entries 3x3"
218+
if group.startswith("exact_hilbert_"):
219+
return f"Hilbert {group.removeprefix('exact_hilbert_')}"
205220
return group
206221

207222

scripts/tests/test_bench_compare.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ def test_dimension_group(self) -> None:
7777
def test_near_singular(self) -> None:
7878
assert bench_compare._group_heading("exact_near_singular_3x3") == "Near-singular 3x3"
7979

80+
def test_large_entries(self) -> None:
81+
assert bench_compare._group_heading("exact_large_entries_3x3") == "Large entries 3x3"
82+
83+
def test_hilbert_4x4(self) -> None:
84+
assert bench_compare._group_heading("exact_hilbert_4x4") == "Hilbert 4x4"
85+
86+
def test_hilbert_5x5(self) -> None:
87+
assert bench_compare._group_heading("exact_hilbert_5x5") == "Hilbert 5x5"
88+
8089
def test_unknown_passthrough(self) -> None:
8190
assert bench_compare._group_heading("something_else") == "something_else"
8291

0 commit comments

Comments
 (0)