Skip to content

Commit 8d5ba07

Browse files
authored
Merge pull request #81 from petercorke/perf/closest-vectorized
perf: closest() and near() use a matrix of coordinates, not a loop
2 parents ffb259d + 5c9cbbb commit 8d5ba07

3 files changed

Lines changed: 385 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
Unreleased
2+
3+
Performance
4+
5+
- `closest()` and `near()` no longer make a Python-level pass over the vertices
6+
when there is no k-d tree. The graph keeps the coordinates as a matrix, built
7+
on first use and appended to by `add_vertex`, and applies the built-in metrics
8+
to all rows at once. A query on a 1,000-vertex graph takes 12 microseconds
9+
rather than 730, and stays that fast while vertices are being added, which is
10+
what the Robotics Toolbox's `LatticePlanner` and `RRTPlanner` do (a lattice of
11+
1,292 vertices is planned in 0.06 s rather than 1.07 s). Results are the
12+
same. The matrix is a copy of the coordinates, so changing one in place,
13+
`vertex.coord[0] = x`, is not seen by `closest()`, as was already so with
14+
`build_kdtree()`; assigning `vertex.coord` is. A metric of your own is still
15+
applied to each vertex in turn.
16+
117
1.0.0
218

319
First 1.0 release: six years of production use since the first commit in 2020

‎src/pgraph/PGraph.py‎

Lines changed: 119 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,42 @@ def __init__(self, tree: Any, index: NDArray, p: float, prepare: Any):
3939
self.prepare = prepare # point -> point for the tree, or None if out of range
4040

4141

42+
class _PointCache:
43+
"""
44+
The coordinates of a graph's embedded vertices, as rows of a matrix (private)
45+
46+
Row ``i`` is the coordinate of ``vertices[i]``, in the order of the graph.
47+
The buffer has spare rows so that adding a vertex is an append, which is
48+
what lets :meth:`Graph.closest` stay fast while a planner interleaves
49+
``add_vertex`` with queries.
50+
"""
51+
52+
def __init__(self, points: NDArray, vertices: list[Vertex]):
53+
n, dim = points.shape
54+
self.buf = np.empty((n + n // 2 + 16, dim))
55+
self.buf[:n] = points
56+
self.n = n
57+
self.vertices = vertices
58+
59+
@property
60+
def dim(self) -> int:
61+
return self.buf.shape[1]
62+
63+
@property
64+
def points(self) -> NDArray:
65+
"""The ``n`` x dim matrix of coordinates (a view, not a copy)"""
66+
return self.buf[: self.n]
67+
68+
def append(self, vertex: Vertex, coord: NDArray) -> None:
69+
if self.n == self.buf.shape[0]:
70+
grown = np.empty((2 * self.n, self.dim))
71+
grown[: self.n] = self.buf
72+
self.buf = grown
73+
self.buf[self.n] = coord
74+
self.n += 1
75+
self.vertices.append(vertex)
76+
77+
4278
class SE2Metric:
4379
r"""
4480
Distance between planar poses, a weighted mixed norm
@@ -294,6 +330,10 @@ def __init__(
294330
# vertices have changed since it was built
295331
self._kdtree: _KDTree | None = None
296332
self._spatialchange = False
333+
# the coordinates of the embedded vertices as a matrix, built when
334+
# first needed and kept up to date by add_vertex; None if not built or
335+
# invalidated by a removal or an assignment to a ``coord``
336+
self._pts: _PointCache | None = None
297337
if metric is None:
298338
self.metric = "L2"
299339
else:
@@ -1003,7 +1043,7 @@ def subgraph(self, vertices: Iterable[Vertex | str] | NDArray) -> Graph:
10031043
# pickling and copying
10041044

10051045
#: the graph's own containers, which are saved as a flat description
1006-
_STRUCTURE = ("_vertexlist", "_vertexdict", "_edges", "_kdtree")
1046+
_STRUCTURE = ("_vertexlist", "_vertexdict", "_edges", "_kdtree", "_pts")
10071047

10081048
#: the references a vertex holds, which are rebuilt rather than saved
10091049
_VERTEX_LINKS = ("_edgelist", "_inlist", "_graph")
@@ -1066,6 +1106,7 @@ def __setstate__(self, state: dict[str, Any]) -> None:
10661106
state[key] = _METRICS[value[1]]
10671107
self.__dict__.update(state)
10681108
self._kdtree = None
1109+
self._pts = None
10691110
self._spatialchange = False
10701111

10711112
self._vertexlist = []
@@ -1209,6 +1250,16 @@ def add_vertex(
12091250
vertex.__dict__["_graph"] = self
12101251
self._connectivitychange = True
12111252
self._spatialchange = True
1253+
pts = self._pts
1254+
if pts is not None and vertex.coord is not None:
1255+
# keep the matrix of coordinates up to date; one that does not
1256+
# fit it (another length, not finite) is left for _points to
1257+
# judge, by dropping the matrix
1258+
coord = vertex.coord
1259+
if coord.ndim == 1 and coord.shape[0] == pts.dim and np.isfinite(coord).all():
1260+
pts.append(vertex, coord)
1261+
else:
1262+
self._pts = None
12121263
return vertex
12131264

12141265
@classmethod
@@ -1494,6 +1545,7 @@ def remove_vertex(self, vertex: Vertex) -> None:
14941545
vertex._label = None # and so not in a component
14951546
self._connectivitychange = True
14961547
self._spatialchange = True
1548+
self._pts = None
14971549

14981550
def remove(self, x: Edge | Vertex) -> None:
14991551
"""
@@ -1986,12 +2038,14 @@ def build_kdtree(self) -> None:
19862038
3 elements, or no vertex has a coordinate
19872039
:raises ImportError: scipy is not installed
19882040
1989-
Finding the nearest vertex to a point is a pass over all the vertices,
1990-
which is the cost of a query on a large graph that is queried often. A
1991-
k-d tree answers in a time that grows more slowly, at the cost of
1992-
building it once. :meth:`closest` and :meth:`near` use the index if it
1993-
has been built, and fall back to the pass over the vertices if it has
1994-
not or if the graph has changed.
2041+
Without an index, finding the nearest vertex to a point measures the
2042+
distance to every vertex, which is the cost of a query on a large graph
2043+
that is queried often. A k-d tree answers in a time that grows more
2044+
slowly, at the cost of building it once, which pays for a graph that is
2045+
built and then queried. A graph that is queried while it is growing
2046+
does better without it, since adding a vertex discards the index.
2047+
:meth:`closest` and :meth:`near` use the index if it has been built,
2048+
and measure every vertex if it has not or if the graph has changed.
19952049
19962050
The index is for the built-in metrics ``"L1"``, ``"L2"`` and
19972051
``"SE2"`` (with any weight, see :class:`SE2Metric`), and results
@@ -2117,6 +2171,36 @@ def _metric_columns(self, diff: NDArray) -> NDArray | None:
21172171
)
21182172
return None
21192173

2174+
def _points(self) -> _PointCache | None:
2175+
"""
2176+
The coordinates of the embedded vertices as a matrix, or None (private)
2177+
2178+
Built on first use and kept by :meth:`add_vertex`. It is None, so
2179+
that the caller makes its pass over the vertices, if no vertex has a
2180+
coordinate or the coordinates are not all vectors of the same length,
2181+
which that pass reports. A vertex whose coordinate is not finite is
2182+
left out, as it is never the closest.
2183+
"""
2184+
if self._pts is not None:
2185+
return self._pts
2186+
vertices = [v for v in self._vertexlist if v.coord is not None]
2187+
if not vertices:
2188+
return None
2189+
try:
2190+
points = np.array([v.coord for v in vertices], dtype=float)
2191+
except (ValueError, TypeError):
2192+
return None # not all the same length, or not numbers
2193+
if points.ndim != 2 or points.shape[1] == 0:
2194+
return None
2195+
finite = np.isfinite(points).all(axis=1)
2196+
if not finite.all():
2197+
points = points[finite]
2198+
vertices = [v for v, ok in zip(vertices, finite) if ok]
2199+
if not vertices:
2200+
return None
2201+
self._pts = _PointCache(points, vertices)
2202+
return self._pts
2203+
21202204
def closest(self, coord: ArrayLike) -> tuple[Vertex | None, float]:
21212205
"""
21222206
Vertex closest to point
@@ -2131,8 +2215,17 @@ def closest(self, coord: ArrayLike) -> tuple[Vertex | None, float]:
21312215
according to the graph's metric. Vertices without a coordinate
21322216
(``coord`` is None) are skipped -- they have no position to compare.
21332217
2134-
This is a pass over the vertices, unless a spatial index has been
2135-
built with :meth:`build_kdtree` and is still valid.
2218+
For the built-in metrics the distance to every vertex is found in one
2219+
operation on a matrix of the coordinates that the graph keeps, which
2220+
adding a vertex only appends to, so this stays fast when vertices are
2221+
added between queries, as a planner does. A spatial index built with
2222+
:meth:`build_kdtree`, if it is still valid, is faster again for a graph
2223+
that is not changing. A metric of your own is applied to each vertex
2224+
in turn.
2225+
2226+
.. note:: The matrix is a copy, so a coordinate changed in place,
2227+
``vertex.coord[0] = x``, is not seen. Assigning
2228+
``vertex.coord = x`` (or ``+=``) is.
21362229
21372230
.. runblock:: pycon
21382231
@@ -2153,6 +2246,15 @@ def closest(self, coord: ArrayLike) -> tuple[Vertex | None, float]:
21532246
if np.isfinite(d):
21542247
return self._vertexlist[int(index.index[i])], float(d)
21552248

2249+
pts = self._points()
2250+
if pts is not None:
2251+
q = np.asarray(coord, dtype=float)
2252+
if q.shape == (pts.dim,) and np.isfinite(q).all():
2253+
d = self._metric_columns((pts.points - q).T)
2254+
if d is not None: # a built-in metric, applied to all at once
2255+
i = int(np.argmin(d))
2256+
return pts.vertices[i], float(d[i])
2257+
21562258
min_dist = np.inf
21572259
min_which: Vertex | None = None
21582260

@@ -2234,9 +2336,9 @@ def near(self, coord: ArrayLike, radius: float) -> list[tuple[Vertex, float]]:
22342336
vertices at equal distance are in the order of the graph.
22352337
22362338
For the built-in metrics the distance to every vertex is found in one
2237-
operation on :meth:`coordinates`, or by the spatial index if
2238-
:meth:`build_kdtree` was called; a metric of your own is applied
2239-
to each vertex in turn.
2339+
operation on the matrix of coordinates that :meth:`closest` uses, or
2340+
by the spatial index if :meth:`build_kdtree` was called; a metric of
2341+
your own is applied to each vertex in turn.
22402342
22412343
.. runblock:: pycon
22422344
@@ -2261,14 +2363,14 @@ def near(self, coord: ArrayLike, radius: float) -> list[tuple[Vertex, float]]:
22612363
candidates = [self._vertexlist[int(index.index[i])] for i in sorted(hits)]
22622364

22632365
if candidates is None:
2264-
C = self.coordinates()
2366+
pts = self._points()
22652367
point = np.asarray(coord, dtype=float)
2266-
if C.shape[0] != 0 and point.shape == (C.shape[0],):
2267-
d = self._metric_columns(C - point[:, None])
2368+
if pts is not None and point.shape == (pts.dim,):
2369+
d = self._metric_columns((pts.points - point).T)
22682370
if d is not None:
22692371
hit = np.flatnonzero(d <= radius)
22702372
hit = hit[np.argsort(d[hit], kind="stable")]
2271-
return [(self._vertexlist[i], float(d[i])) for i in hit]
2373+
return [(pts.vertices[i], float(d[i])) for i in hit]
22722374
candidates = [v for v in self._vertexlist if v.coord is not None]
22732375

22742376
found = []
@@ -4531,6 +4633,7 @@ def __setattr__(self, name: str, value: Any) -> None:
45314633
graph = self.__dict__.get("_graph")
45324634
if graph is not None:
45334635
graph._spatialchange = True
4636+
graph._pts = None
45344637
object.__setattr__(self, name, value)
45354638

45364639
def __reduce__(self) -> Any:

0 commit comments

Comments
 (0)