Skip to content

Commit 9b380a1

Browse files
committed
Add batched simplices_containing query and Rust default_loss
Profiling LearnerND with the Rust backend (adaptive PR #493) showed only 14-25% of runtime left inside this extension; two of the remaining Python hotspots are triangulation-shaped and move here: - Triangulation.simplices_containing(point, simplex=None, candidates=None, eps=1e-8): all simplices containing a point in one call. Instead of one barycentric solve per neighbouring simplex (the loop LearnerND.tell_pending runs in Python today, ~14 checks per point in 2D, ~70 in 3D), it solves once for a simplex known to contain the point (the hint, or locate_point), reduces it to the face the point lies on, and looks up the simplices containing that face in the facet index. - default_loss(simplex, values, value_scale=None): the LearnerND default loss (embedded simplex volume) taking the scaled vertex/value arrays directly, signature-compatible with loss_per_simplex. End-to-end with both wired into LearnerND (examples/learnernd_batched_apis.py): 1.18x in 2D and 1.39x in 3D on top of the Rust backend, with identical sampled points.
1 parent c6d6de4 commit 9b380a1

10 files changed

Lines changed: 479 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ tri.circumscribed_circle(simplex) # → (center, radius)
9292
tri.volume(simplex) # Simplex volume
9393
tri.volumes() # All simplex volumes
9494
tri.point_in_simplex(point, simplex) # Containment test
95+
tri.simplices_containing(point) # All simplices containing a point, in one call
96+
# instead of a point_in_simplex loop; pass a known
97+
# containing simplex via simplex=... to skip the
98+
# locate step, or restrict with candidates=...
9599
tri.point_in_circumcircle(pt, simplex) # Circumcircle test
96100
tri.bowyer_watson(pt_index) # Direct Bowyer-Watson
97101
tri.get_opposing_vertices(simplex) # Facet neighbours' opposite vertices
@@ -111,6 +115,8 @@ from adaptive_triangulation import (
111115
point_in_simplex, # Containment test
112116
volume, # Simplex volume
113117
simplex_volume_in_embedding, # Volume in embedding space
118+
default_loss, # LearnerND's default loss (embedded simplex volume),
119+
# signature-compatible with loss_per_simplex
114120
orientation, # Face orientation
115121
)
116122
```

‎examples/learnernd_batched_apis.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Measure the LearnerND speedup from `simplices_containing` and `default_loss`.
2+
3+
This monkeypatches adaptive (the PR #493 branch, with the Rust backend
4+
active) to use the two batched APIs, the way the adaptive-side integration
5+
would:
6+
7+
- ``tell_pending`` distributes a pending point over the simplices containing
8+
it with one ``tri.simplices_containing(point, simplex=hint)`` call instead
9+
of a Python loop of per-neighbour ``tri.point_in_simplex`` checks.
10+
- The default loss is the Rust ``default_loss`` instead of the Python
11+
wrapper around ``simplex_volume_in_embedding``.
12+
13+
Requirements:
14+
pip install adaptive-triangulation
15+
pip install "adaptive @ git+https://github.com/python-adaptive/adaptive@refs/pull/493/head"
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import time
21+
from typing import TYPE_CHECKING
22+
23+
import adaptive_triangulation as at
24+
import numpy as np
25+
from adaptive.learner import learnerND as lnd_mod
26+
from adaptive.learner.learnerND import LearnerND
27+
28+
if TYPE_CHECKING:
29+
from collections.abc import Callable, Sequence
30+
31+
reference_default_loss = lnd_mod.default_loss
32+
33+
34+
def ring_of_fire(xy: Sequence[float]) -> float:
35+
x, y = xy
36+
a, d = 0.2, 0.5
37+
return x + np.exp(-((x**2 + y**2 - d**2) ** 2) / a**4)
38+
39+
40+
def sphere_of_fire(xyz: Sequence[float]) -> float:
41+
x, y, z = xyz
42+
a, d = 0.2, 0.5
43+
return x + np.exp(-((x**2 + y**2 + z**2 - d**2) ** 2) / a**4)
44+
45+
46+
def _add_pending_to_simplex(self: LearnerND, point: tuple, simplex: tuple) -> tuple:
47+
"""`_try_adding_pending_point_to_simplex` without the containment check
48+
(the caller already knows `simplex` contains `point`)."""
49+
if simplex not in self._subtriangulations:
50+
vertices = self.tri.get_vertices(simplex)
51+
self._subtriangulations[simplex] = self._triangulation_class(vertices)
52+
self._pending_to_simplex[point] = simplex
53+
return self._subtriangulations[simplex].add_point(point)
54+
55+
56+
def tell_pending_batched(self: LearnerND, point: tuple, *, simplex: tuple | None = None) -> None:
57+
"""tell_pending with the point_in_simplex loop replaced by one
58+
`simplices_containing` call (the `simplex` argument becomes the hint)."""
59+
point = tuple(point)
60+
if not self.inside_bounds(point):
61+
return
62+
self.pending_points.add(point)
63+
if self.tri is None:
64+
return
65+
containing = self.tri.simplices_containing(point, simplex=simplex)
66+
for simpl in containing:
67+
_, to_add = _add_pending_to_simplex(self, point, simpl)
68+
if to_add is None:
69+
continue
70+
self._update_subsimplex_losses(simpl, to_add)
71+
72+
73+
def run(
74+
func: Callable[[Sequence[float]], float],
75+
bounds: list[tuple[float, float]],
76+
n_points: int,
77+
*,
78+
rust_loss: bool,
79+
batched_tell: bool,
80+
) -> tuple[float, LearnerND]:
81+
lnd_mod.default_loss = at.default_loss if rust_loss else reference_default_loss
82+
learner = LearnerND(func, bounds=bounds)
83+
if batched_tell:
84+
learner.tell_pending = tell_pending_batched.__get__(learner)
85+
t0 = time.perf_counter()
86+
for _ in range(n_points):
87+
points, _ = learner.ask(1)
88+
for p in points:
89+
learner.tell(p, func(p))
90+
elapsed = time.perf_counter() - t0
91+
return elapsed, learner
92+
93+
94+
CONFIGS = [
95+
("baseline (Rust backend, PR #493)", False, False),
96+
("+ rust default_loss", True, False),
97+
("+ simplices_containing tell_pending", False, True),
98+
("+ both", True, True),
99+
]
100+
101+
for name, func, bounds, n in [
102+
("2D ring_of_fire", ring_of_fire, [(-1, 1), (-1, 1)], 3000),
103+
("3D sphere_of_fire", sphere_of_fire, [(-1, 1), (-1, 1), (-1, 1)], 1500),
104+
]:
105+
print(f"\n{name}, {n} points:")
106+
baseline = None
107+
base_points = None
108+
for label, rust_loss, batched_tell in CONFIGS:
109+
best = min(
110+
run(func, bounds, n, rust_loss=rust_loss, batched_tell=batched_tell)[0]
111+
for _ in range(3)
112+
)
113+
_, learner = run(func, bounds, n, rust_loss=rust_loss, batched_tell=batched_tell)
114+
pts = sorted(learner.data)
115+
if baseline is None:
116+
baseline, base_points = best, pts
117+
same = "identical points" if pts == base_points else "DIFFERENT POINTS"
118+
print(f" {label:40s} {best:6.2f}s ({baseline / best:4.2f}x, {same})")

‎python/adaptive_triangulation/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
VerticesProxy,
3030
__version__,
3131
circumsphere,
32+
default_loss,
3233
fast_2d_circumcircle,
3334
fast_2d_point_in_simplex,
3435
fast_3d_circumcircle,
@@ -47,6 +48,7 @@
4748
"VerticesProxy",
4849
"__version__",
4950
"circumsphere",
51+
"default_loss",
5052
"fast_2d_circumcircle",
5153
"fast_2d_point_in_simplex",
5254
"fast_3d_circumcircle",

‎python/adaptive_triangulation/__init__.pyi‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ from ._rust import (
1616
from ._rust import (
1717
circumsphere as circumsphere,
1818
)
19+
from ._rust import (
20+
default_loss as default_loss,
21+
)
1922
from ._rust import (
2023
fast_2d_circumcircle as fast_2d_circumcircle,
2124
)
@@ -51,6 +54,7 @@ __all__: list[str] = [
5154
"VerticesProxy",
5255
"__version__",
5356
"circumsphere",
57+
"default_loss",
5458
"fast_2d_circumcircle",
5559
"fast_2d_point_in_simplex",
5660
"fast_3d_circumcircle",

‎python/adaptive_triangulation/_rust.pyi‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ class Triangulation:
9494
simplex: SimplexLike,
9595
eps: float = 1e-8,
9696
) -> bool: ...
97+
def simplices_containing(
98+
self,
99+
point: PointLike,
100+
simplex: SimplexLike | None = None,
101+
candidates: Iterable[SimplexLike] | None = None,
102+
eps: float = 1e-8,
103+
) -> list[Simplex]: ...
97104
def bowyer_watson(
98105
self,
99106
pt_index: int,
@@ -104,6 +111,11 @@ class Triangulation:
104111
def convex_invariant(self, vertex: int) -> bool: ...
105112

106113
def circumsphere(points: PointsLike) -> tuple[tuple[float, ...], float]: ...
114+
def default_loss(
115+
simplex: PointsLike,
116+
values: Sequence[float] | PointsLike,
117+
value_scale: float | None = None,
118+
) -> float: ...
107119
def fast_2d_circumcircle(points: PointsLike) -> tuple[tuple[float, ...], float]: ...
108120
def fast_2d_point_in_simplex(point: PointLike, simplex: PointsLike, eps: float = 1e-8) -> bool: ...
109121
def fast_3d_circumcircle(points: PointsLike) -> tuple[tuple[float, ...], float]: ...

‎src/geometry.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,19 @@ pub fn simplex_volume_in_embedding(vertices: &[Vec<f64>]) -> Result<f64, Geometr
666666
Ok(vol_square.sqrt())
667667
}
668668

669+
/// The loss adaptive's `LearnerND` assigns to a simplex by default: the
670+
/// volume of the simplex embedded in (input + output)-dimensional space,
671+
/// where each vertex is extended with its function value(s). `simplex` and
672+
/// `values` are zipped pairwise, like the reference implementation.
673+
pub fn default_loss(simplex: &[Vec<f64>], values: &[Vec<f64>]) -> Result<f64, GeometryError> {
674+
let embedded: Vec<Vec<f64>> = simplex
675+
.iter()
676+
.zip(values)
677+
.map(|(vertex, value)| vertex.iter().chain(value.iter()).copied().collect())
678+
.collect();
679+
simplex_volume_in_embedding(&embedded)
680+
}
681+
669682
#[cfg(test)]
670683
mod tests {
671684
use super::*;

‎src/lib.rs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod py;
88
pub mod tolerances;
99
pub mod triangulation;
1010

11+
use numpy::{PyReadonlyArray1, PyReadonlyArray2};
1112
use pyo3::exceptions::{PyValueError, PyZeroDivisionError};
1213
use pyo3::prelude::*;
1314
use pyo3::types::{PyAny, PyModule, PyTuple};
@@ -47,6 +48,7 @@ fn _rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
4748
m.add_function(wrap_pyfunction!(py_fast_norm, m)?)?;
4849
m.add_function(wrap_pyfunction!(py_volume, m)?)?;
4950
m.add_function(wrap_pyfunction!(py_simplex_volume_in_embedding, m)?)?;
51+
m.add_function(wrap_pyfunction!(py_default_loss, m)?)?;
5052
m.add_function(wrap_pyfunction!(py_orientation, m)?)?;
5153

5254
Ok(())
@@ -176,6 +178,54 @@ fn py_simplex_volume_in_embedding(vertices: &Bound<'_, PyAny>) -> PyResult<f64>
176178
.map_err(|err| PyValueError::new_err(err.to_string()))
177179
}
178180

181+
/// Parse per-vertex function values: one scalar per vertex or one vector per
182+
/// vertex, with bulk-copy fast paths for 1-D and 2-D f64 numpy arrays (what
183+
/// `LearnerND._compute_loss` passes after scaling).
184+
fn parse_values(obj: &Bound<'_, PyAny>) -> PyResult<Vec<Vec<f64>>> {
185+
if let Ok(array) = obj.extract::<PyReadonlyArray1<'_, f64>>() {
186+
return Ok(array.as_array().iter().map(|&value| vec![value]).collect());
187+
}
188+
if let Ok(array) = obj.extract::<PyReadonlyArray2<'_, f64>>() {
189+
return Ok(array
190+
.as_array()
191+
.outer_iter()
192+
.map(|row| row.to_vec())
193+
.collect());
194+
}
195+
let Ok(iter) = obj.try_iter() else {
196+
return Err(PyValueError::new_err(
197+
"Expected one scalar or one vector of floats per vertex",
198+
));
199+
};
200+
let mut values = Vec::new();
201+
for item in iter {
202+
let item = item?;
203+
match item.extract::<f64>() {
204+
Ok(value) => values.push(vec![value]),
205+
Err(_) => values.push(parse_point(&item)?),
206+
}
207+
}
208+
Ok(values)
209+
}
210+
211+
/// The default `LearnerND` loss: the volume of the simplex embedded in
212+
/// (input + output)-dimensional space, where each vertex is extended with
213+
/// its function value(s). Signature-compatible with adaptive's
214+
/// `loss_per_simplex` functions; `value_scale` is accepted and unused, like
215+
/// the reference (`values` arrive already scaled).
216+
#[pyfunction]
217+
#[pyo3(name = "default_loss", signature = (simplex, values, value_scale=None))]
218+
fn py_default_loss(
219+
simplex: &Bound<'_, PyAny>,
220+
values: &Bound<'_, PyAny>,
221+
value_scale: Option<&Bound<'_, PyAny>>,
222+
) -> PyResult<f64> {
223+
let _ = value_scale;
224+
let simplex = parse_points_sized(simplex, "Please provide a 2-dimensional list of points")?;
225+
let values = parse_values(values)?;
226+
geom::default_loss(&simplex, &values).map_err(|err| PyValueError::new_err(err.to_string()))
227+
}
228+
179229
#[pyfunction]
180230
#[pyo3(name = "orientation")]
181231
fn py_orientation(face: &Bound<'_, PyAny>, origin: &Bound<'_, PyAny>) -> PyResult<i32> {

‎src/py.rs‎

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ impl From<TriangulationError> for PyErr {
3333
}
3434
}
3535

36+
/// Error mapping for point-in-simplex checks: the reference implementation
37+
/// divides by the simplex volume and solves a linear system, so degenerate
38+
/// simplices raise `ZeroDivisionError` and singular solves raise
39+
/// `numpy.linalg.LinAlgError`.
40+
fn point_in_simplex_error(py: Python<'_>, error: TriangulationError) -> PyErr {
41+
match error {
42+
TriangulationError::Geometry(GeometryError::DegenerateSimplex) => {
43+
PyZeroDivisionError::new_err("division by zero")
44+
}
45+
TriangulationError::Geometry(GeometryError::SingularMatrix) => {
46+
numpy_linalg_error(py, "Singular matrix")
47+
}
48+
other => other.into(),
49+
}
50+
}
51+
3652
pub(crate) fn parse_point(obj: &Bound<'_, PyAny>) -> PyResult<Vec<f64>> {
3753
// Bulk-copy f64 numpy arrays instead of iterating Python objects.
3854
if let Ok(array) = obj.extract::<PyReadonlyArray1<'_, f64>>() {
@@ -875,16 +891,51 @@ impl PyTriangulation {
875891
) -> PyResult<bool> {
876892
let point = parse_point(point)?;
877893
let simplex = ordered_indices_from_py(simplex, self.core.vertices.len())?;
878-
match self.core.point_in_simplex(&point, &simplex, eps) {
879-
Ok(result) => Ok(result),
880-
Err(TriangulationError::Geometry(GeometryError::DegenerateSimplex)) => {
881-
Err(PyZeroDivisionError::new_err("division by zero"))
882-
}
883-
Err(TriangulationError::Geometry(GeometryError::SingularMatrix)) => {
884-
Err(numpy_linalg_error(py, "Singular matrix"))
894+
self.core
895+
.point_in_simplex(&point, &simplex, eps)
896+
.map_err(|error| point_in_simplex_error(py, error))
897+
}
898+
899+
/// All simplices containing `point`, as a sorted list of tuples: among
900+
/// `candidates` when given, otherwise found from a simplex containing
901+
/// the point (the `simplex` hint when it holds the point, else
902+
/// `locate_point`). One call replaces a Python-side loop of
903+
/// `point_in_simplex` checks (the hot loop in adaptive's
904+
/// `LearnerND.tell_pending`, whose `simplex` argument maps onto the
905+
/// hint).
906+
#[pyo3(signature = (point, simplex=None, candidates=None, eps=BARYCENTRIC_EPS))]
907+
fn simplices_containing(
908+
&self,
909+
py: Python<'_>,
910+
point: &Bound<'_, PyAny>,
911+
simplex: Option<&Bound<'_, PyAny>>,
912+
candidates: Option<&Bound<'_, PyAny>>,
913+
eps: f64,
914+
) -> PyResult<Py<PyAny>> {
915+
let point = parse_point(point)?;
916+
let hint = match given(simplex) {
917+
None => None,
918+
Some(value) => Some(canonical_simplex_from_py(value, self.core.vertices.len())?),
919+
};
920+
let candidates = match given(candidates) {
921+
None => None,
922+
Some(value) => {
923+
let mut canonical = FxHashSet::default();
924+
for simplex in parse_signed_simplex_set(value)? {
925+
canonical.insert(canonicalize_simplex(&simplex, self.core.vertices.len())?);
926+
}
927+
Some(canonical.into_iter().collect())
885928
}
886-
Err(other) => Err(other.into()),
887-
}
929+
};
930+
let containing = self
931+
.core
932+
.simplices_containing_point(&point, candidates, hint.as_deref(), eps)
933+
.map_err(|error| point_in_simplex_error(py, error))?;
934+
let items: Vec<Py<PyAny>> = containing
935+
.iter()
936+
.map(|simplex| simplex_tuple(py, simplex).into())
937+
.collect();
938+
Ok(PyList::new(py, items)?.into())
888939
}
889940

890941
#[pyo3(signature = (pt_index, containing_simplex=None, transform=None))]

0 commit comments

Comments
 (0)