Skip to content

Performance: closest() is a Python loop on the interleaved add/query path used by RTB planners #74

Open

Description

@petercorke

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 and vertex.coord = ... set _spatialchange, which drops the tree on the next _index().
  • Without a tree, closest() calls self.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

  1. Profile the real planners (cProfile of PRMPlanner.plan, RRTPlanner.plan, LatticePlanner.plan) and report the share attributable to pgraph. No repo changes.
  2. 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 and coord assignment invalidate it, and it rebuilds lazily. Expected ~10-20 µs at n=1000 (to be measured), which works with interleaved inserts where a cKDTree cannot.
    • Caveat: the matrix is a copy, so an in-place change (v.coord[0] = x) is not seen by closest; this is the same limitation build_kdtree already documents. Assigning v.coord = ... still works.
  3. 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/closest is benchmarked against the loop, with the loop as the test oracle.

Activity

  1. petercorke commented on Oct 13, 2026

    @petercorke
    OwnerAuthor

    Step 1 result: profiling the RTB planners

    cProfile of RTB (feat/symbolic-ik, editable) against pgraph main (74402d5). "pgraph share" is tottime of functions defined in pgraph/, as a fraction of the profiled time. Scripts were run once, on one machine, with RTB's own example maps.

    Planner Size Wall pgraph share Dominant cost
    PRM plan 100 / 400 points 0.15 s / 2.65 s 8% (400) collision checks (OccGrid.w2g/isoccupied, 637k calls)
    RRT plan 50 / 200 nodes 2.6 s / 32.5 s 1% Dubins and polygon collision checking
    Lattice plan 372 / 780 / 1292 vertices 0.13 / 0.68 / 1.9 s 84% / 88% / 90% Graph.closest(): 2.2M metric calls at 1292 vertices
    Queries (all) <= 0.02 s n/a negligible

    What this changes

    • The hypothesis above is wrong for RRT and PRM: the k-d tree is never used there, but pgraph is 1% and 8% of the time, so a faster closest would not move them at these sizes.
    • Lattice is the case that matters: ~90% of plan time is the brute-force closest() loop, growing ~3x per iteration. It interleaves add_vertex with closest, so an explicit build_kdtree() (option 3 in the proposal) cannot help it; the vectorized closest (option 2) can.
    • So the proposal narrows to the vectorized closest, benchmarked on the Lattice workload; the RTB build_kdtree() change is dropped unless larger PRM/RRT sizes show otherwise.

    Caveats

    • Small sizes only; collision checking may also scale with size, so the balance for PRM/RRT at larger sizes is unmeasured.
    • Profiler overhead inflates call-heavy code; shares are approximate.
    • PoseGraph and BundleAdjust not yet profiled.
  2. petercorke commented on Oct 13, 2026

    @petercorke
    OwnerAuthor

    Step 1, continued: RRT breakdown, PoseGraph, BundleAdjust

    Correction to the previous comment. RRTPlanner.plan() defaults to showsamples=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:

    Nodes Total Vehicle.polygon() (SE2 + transform) Polygon2.intersects Dubins query pgraph self
    100 1.80 s 0.94 s (52%) 0.35 s (19%) 0.38 s (21%) 0.07 s (4%)
    400 16.7 s 8.14 s (49%) 3.06 s (18%) 2.93 s (18%) 2.09 s (12.5%)
    • Within RRT collision checking, the polygon transform (~30 us/call, building the SE2) costs ~2.7x the intersection test (~11 us/call). That is an RTB/spatialmath cost, not pgraph.
    • pgraph's share is not 1%: it grows with the node count (4% -> 12.5% from 100 to 400 nodes), consistent with closest() being quadratic over an interleaved add/query loop, so RRT also benefits from a faster closest() at larger sizes.

    PoseGraph (killian-small.toro 1,941 vertices / 3,995 edges; killian.g2o 3,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, including ndarray.nonzero (0.39 s / 1.1 s over 3-5 calls) and ~60k np.pad calls, 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

    Algorithm pgraph share Dominant cost
    Lattice plan ~90% closest() brute force
    RRT plan 4% -> 12.5% (growing) polygon transform, intersects, Dubins
    PRM plan 8% occupancy-grid collision checks
    PoseGraph, BundleAdjust <= 1% numpy/scipy, camera Jacobians

    Same 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.

  3. petercorke commented on Oct 17, 2026

    @petercorke
    OwnerAuthor

    Implemented: #81

    The vectorized closest() / near() (proposal option 2) is in #81. Measured, main against the branch:

    • closest, N=1,000: 726 us -> 11.9 us
    • LatticePlanner.plan, 1,292 vertices: 1.07 s -> 0.06 s
    • RRTPlanner.plan, 300 nodes: 5.1 s -> 4.3 s
    • RRT, PRM and Lattice produce identical vertices, edges and paths; add_vertex is unchanged.

    Left open

    • Assign-after pattern. v = g.add_vertex(); v.coord = x drops the matrix, so each following query rebuilds it (O(n); 0.21 ms against 1.34 ms on main at n=2,000). Passing the coordinate to add_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.
    • RTB side: Performance: RRTPlanner polygon transforms, PoseGraph.optimize nonzero/pad, and where build_kdtree() would help robotics-toolbox-python#755 (polygon transforms, PoseGraph.optimize, and where build_kdtree() would help).
    • In-place edits (v.coord[0] = x) are not seen by closest; documented.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions