Repository navigation
Performance: closest() is a Python loop on the interleaved add/query path used by RTB planners #74
Description
Summary
Graph.closest() is a Python loop over every vertex unless build_kdtree() has been called, and any add_vertex, vertex removal or coord assignment discards the tree. The planners in RTB that use pgraph interleave add_vertex with closest, so they probably always take the slow path. This issue records the analysis and a plan; nothing here has been profiled in the real planners yet.
What the code does
build_kdtree()is the only place the index is set;closest()never builds it (src/pgraph/PGraph.py,_index()).add_vertex, vertex removal andvertex.coord = ...set_spatialchange, which drops the tree on the next_index().- Without a tree,
closest()callsself.metric(vertex.coord - coord)once per vertex.
Measured (examples/benchmark.py --networkx, 1,000 vertices)
| Operation | pgraph | networkx |
|---|---|---|
| closest vertex, no tree (µs/query) | 683 | 195 |
| closest vertex, k-d tree (µs/query) | 6.22 | 4.73 |
Also measured (2-D, one machine): gathering coordinates for 100k vertices takes 11.8 ms, a cKDTree build 16.0 ms; reading v.coord costs 4.3 ms per 100k vertices against 5.6 ms for a row view of a matrix. A __slots__ Vertex was ~5% faster to create and 14% smaller, with no change in attribute access, and would need every vertex.__dict__ use rewritten, so it is not worth doing.
How RTB and MVTB call pgraph (from the sources, not profiled)
| Algorithm | Build phase | Query phase |
|---|---|---|
| PRM | add_vertex; vertex.distance(other) for every pair; add_edge |
closest x2, path_Astar |
| RRT | loop: closest -> add_vertex -> add_edge |
closest, path_UCS |
| Lattice | closest interleaved with add_vertex |
closest x2, path_Astar; plotting calls closest per edge |
| PoseGraph, BundleAdjust | add_vertex, edges() |
mostly numeric |
Hypothesis: RRT and Lattice pay the full Python loop on every iteration (quadratic overall); PRM and the query phases would benefit from a tree that nothing builds.
Proposal
- Profile the real planners (
cProfileofPRMPlanner.plan,RRTPlanner.plan,LatticePlanner.plan) and report the share attributable to pgraph. No repo changes. - pgraph: vectorized
closest. Keep a graph-owned, append-friendly (capacity-doubling) coordinate matrix and a row-to-vertex map, and apply the built-in metrics to all rows at once with the existing_metric_columns; a user-supplied metric keeps the loop. Removal andcoordassignment invalidate it, and it rebuilds lazily. Expected ~10-20 µs at n=1000 (to be measured), which works with interleaved inserts where acKDTreecannot.- Caveat: the matrix is a copy, so an in-place change (
v.coord[0] = x) is not seen byclosest; this is the same limitationbuild_kdtreealready documents. Assigningv.coord = ...still works.
- Caveat: the matrix is a copy, so an in-place change (
- RTB: call
build_kdtree()in the planners where the graph has stopped changing (PRM after sampling, Lattice before the query and plotting phase). Needs a guard or version floor for older pgraph, and care with custom metrics and no-scipy installs. Separate RTB branch.
Not proposed: automatic tree building inside closest() (hides a scipy import and an O(n log n) pause in a query), or making coord a view into a central matrix (large change; scalar access gets slower).
Done when
- The profile shows where pgraph time goes in the three planners, recorded here.
- Interleaved
add_vertex/closestis benchmarked against the loop, with the loop as the test oracle.
Activity
petercorke commentedon Oct 13, 2026
Step 1 result: profiling the RTB planners
cProfileof RTB (feat/symbolic-ik, editable) against pgraphmain(74402d5). "pgraph share" istottimeof functions defined inpgraph/, as a fraction of the profiled time. Scripts were run once, on one machine, with RTB's own example maps.OccGrid.w2g/isoccupied, 637k calls)Graph.closest(): 2.2M metric calls at 1292 verticesWhat this changes
closestwould not move them at these sizes.closest()loop, growing ~3x per iteration. It interleavesadd_vertexwithclosest, so an explicitbuild_kdtree()(option 3 in the proposal) cannot help it; the vectorizedclosest(option 2) can.closest, benchmarked on the Lattice workload; the RTBbuild_kdtree()change is dropped unless larger PRM/RRT sizes show otherwise.Caveats
petercorke commentedon Oct 13, 2026
Step 1, continued: RRT breakdown, PoseGraph, BundleAdjust
Correction to the previous comment.
RRTPlanner.plan()defaults toshowsamples=True, showvalid=True, so the RRT figures there included matplotlib drawing (3.7 s of 9.1 s at 100 nodes). Re-run with plotting off:Vehicle.polygon()(SE2 + transform)Polygon2.intersectsSE2) costs ~2.7x the intersection test (~11 us/call). That is an RTB/spatialmath cost, not pgraph.closest()being quadratic over an interleaved add/query loop, so RRT also benefits from a fasterclosest()at larger sizes.PoseGraph (
killian-small.toro1,941 vertices / 3,995 edges;killian.g2o3,873 / 4,987):load is 19-33% pgraph but 0.04-0.1 s in total;
optimize(iterations=5)takes ~2.1 s / ~2.5 s with pgraph at 1%. The time is in numpy/scipy, includingndarray.nonzero(0.39 s / 1.1 s over 3-5 calls) and ~60knp.padcalls, which look like RTB-side costs.BundleAdjust (10 views x 300 landmarks, 3,000 projections): build 0.07 s (pgraph 10%), optimize 2.8 s with pgraph at 0.1%; the time is camera Jacobians (
cameraModel, 1.0 s) and scipy sparse indexing.Summary
closest()brute forceSame caveats as before: single runs, one machine, RTB's example data, profiler overhead. Scripts were run from the session scratchpad and are not in the repo.
Plan: implement the vectorized
closest()(proposal option 2) and benchmark on the Lattice and RRT workloads.petercorke commentedon Oct 17, 2026
Implemented: #81
The vectorized
closest()/near()(proposal option 2) is in #81. Measured,mainagainst the branch:closest, N=1,000: 726 us -> 11.9 usLatticePlanner.plan, 1,292 vertices: 1.07 s -> 0.06 sRRTPlanner.plan, 300 nodes: 5.1 s -> 4.3 sadd_vertexis unchanged.Left open
v = g.add_vertex(); v.coord = xdrops the matrix, so each following query rebuilds it (O(n); 0.21 ms against 1.34 ms onmainat n=2,000). Passing the coordinate toadd_vertex(coord=x)or the vertex constructor avoids it. A fix would give every vertex a row (NaN when it has no valid coordinate), aligned with the vertex list, and overwrite the row on assignment; not done because no RTB or MVTB code path assigns after adding.PoseGraph.optimize, and wherebuild_kdtree()would help).v.coord[0] = x) are not seen byclosest; documented.