# Performance Three questions: how meshsearch compares to the alternatives, how to choose the cell side, and what to expect on data unlike the data measured here. The numbers are from an Apple M1 Pro (10 cores, 16 GiB, macOS 14.5) with Python 3.14.7, numpy 2.5.3, scipy 1.18.1, scikit-learn 1.9.1, and meshsearch built with AppleClang at `-O3`. Absolute times are machine-specific; the ratios between libraries travel better than the microseconds do. Treat a difference under about 10 per cent as a tie. ## Against cKDTree and BallTree Three queries are measured, named here as the Python bindings spell them and identical to their C++ counterparts: | in the tables | the call | what it asks | |---|---|---| | **nearest** | `grid.nearest_object(x, y, z)` | the one nearest object | | **k = 10** | `grid.nearest_objects(10, x, y, z)` | the ten nearest, nearest first | | **radius** | `grid.close_objects(x, y, z, rmax)` | every object within `rmax` | `rmax` is twice the mean separation of the data, which holds the answer at about 33 neighbours at every size, so one query does the same amount of work as the data grows. cKDTree and BallTree answer the same three questions, through `query`, `query` with `k=10`, and `query_ball_point` / `query_radius`, and return the same neighbours. The points are uniform in the unit cube, and the queries are 1000 uniform query points. Query times are **microseconds for one query point**. The per-call rows call the library once per point; the batched rows hand all 1000 points over in a single call. Memory is the resident set the structure occupies. meshsearch is tabulated at two cell sides, because it has one and the trees do not. One mean separation gives about one object per cell; four gives about 64. **Cell side of one mean separation** | points | | meshsearch | cKDTree | BallTree | |---|---|---|---|---| | **1e5** | construction (s) | 0.016 | 0.029 | 0.030 | | | memory (MiB) | 29 | 26 | 88 | | | nearest, per call | 1.09 | 8.48 | 35.67 | | | nearest, batched | 0.33 | 0.83 | 3.75 | | | k = 10, per call | 5.35 | 11.40 | 39.69 | | | k = 10, batched | 4.13 | 2.03 | 7.77 | | | radius, per call | 5.36 | 5.97 | 49.11 | | | radius, batched | 4.16 | 4.00 | 3.83 | | **1e6** | construction (s) | 0.199 | 0.387 | 0.394 | | | memory (MiB) | 299 | 53 | 96 | | | nearest, per call | 1.33 | 9.45 | 53.37 | | | nearest, batched | 0.42 | 1.31 | 21.67 | | | k = 10, per call | 13.33 | 12.74 | 63.88 | | | k = 10, batched | 11.64 | 3.58 | 34.22 | | | radius, per call | 12.32 | 8.92 | 850.24 | | | radius, batched | 10.86 | 7.06 | 10.91 | | **1e7** | construction (s) | 2.808 | 5.822 | 6.326 | | | memory (MiB) | 1,495 | 400 | 177 | | | nearest, per call | 1.79 | 8.66 | 81.26 | | | nearest, batched | 0.58 | 1.42 | 51.85 | | | k = 10, per call | 21.86 | 12.56 | 103.54 | | | k = 10, batched | 16.03 | 3.86 | 73.02 | | | radius, per call | 17.17 | 10.78 | 9,005.79 | | | radius, batched | 14.40 | 8.79 | 23.17 | **Cell side of four mean separations** | points | | meshsearch | cKDTree | BallTree | |---|---|---|---|---| | **1e5** | construction (s) | 0.011 | 0.029 | 0.027 | | | memory (MiB) | 10 | 28 | 87 | | | nearest, per call | 2.61 | 8.79 | 34.97 | | | nearest, batched | 1.91 | 0.91 | 4.04 | | | k = 10, per call | 12.39 | 10.58 | 39.49 | | | k = 10, batched | 11.21 | 2.27 | 8.37 | | | radius, per call | 3.85 | 6.09 | 48.27 | | | radius, batched | 3.09 | 4.56 | 3.94 | | **1e6** | construction (s) | 0.134 | 0.365 | 0.366 | | | memory (MiB) | 123 | 54 | 97 | | | nearest, per call | 4.63 | 8.69 | 46.85 | | | nearest, batched | 3.39 | 1.17 | 17.75 | | | k = 10, per call | 18.60 | 11.61 | 57.03 | | | k = 10, batched | 17.12 | 3.28 | 26.91 | | | radius, per call | 6.13 | 8.15 | 811.82 | | | radius, batched | 4.48 | 6.24 | 9.55 | | **1e7** | construction (s) | 1.644 | 6.107 | 6.610 | | | memory (MiB) | 672 | 400 | 177 | | | nearest, per call | 8.42 | 9.60 | 85.37 | | | nearest, batched | 7.78 | 1.43 | 53.83 | | | k = 10, per call | 34.34 | 13.73 | 104.41 | | | k = 10, batched | 32.37 | 4.54 | 76.44 | | | radius, per call | 9.71 | 11.08 | 10,016.19 | | | radius, batched | 8.76 | 9.22 | 26.97 | ### What the numbers say **Construction is faster everywhere** — about 2x against cKDTree at one mean separation, 2.6x to 3.7x at four. **Memory is the weakest column at a fine cell side and the strongest at a coarse one.** At one mean separation meshsearch holds 1.5 GiB at 1e7 against cKDTree's 400 MiB; at four mean separations, 672 MiB, and at 1e5 it is a third of cKDTree's. **Nearest neighbour, one mean separation: meshsearch wins in both calling styles.** Per call it is 5x to 8x faster than cKDTree; batched, 0.33, 0.42 and 0.58 microseconds against 0.83, 1.31 and 1.42, so 2.4x to 3.1x faster. At four mean separations this reverses, 2.1x to 5.4x slower batched, because each cell holds 64 candidates to test. **Radius, four mean separations: batched meshsearch is the fastest thing measured.** 3.09, 4.48 and 8.76 microseconds against batched cKDTree's 4.56, 6.24 and 9.22, so 1.05x to 1.48x faster at all three sizes. At one mean separation it is 1.0x to 1.6x slower than batched cKDTree. **k = 10 is the query to take elsewhere.** Against batched cKDTree, meshsearch is 2.0x to 4.2x slower at one mean separation and 4.9x to 7.1x at four. The gap is algorithmic, so no cell side and no calling style closes it: if this query dominates, use a tree. **BallTree loses every row.** Never call it once per point: its per-call radius query is 9 milliseconds at 1e7 against 23 microseconds batched. ### Per call or batched A call from Python costs about 1 microsecond before any work happens, against 7 to 8 for scipy. That is what decides how much batching can win: a query taking 1 microsecond gets most of its time back, one taking 15 gets under 10 per cent. In these tables batching gains 3.1x to 3.3x on nearest neighbour at one mean separation and 1.1x to 1.4x on everything else. If a query is already slow, batching is not what will fix it. The batched forms exist in the Python bindings only. A C++ caller has no boundary to cross and writes a loop. ## Choosing the cell side The cell side is the one tuning parameter, and it is not a single trade-off: the best choice for one query kind is not the best for another. Query times in microseconds, with `rmax` again at twice the mean separation. **1e6 points** | cell side | cells | objects per cell | build (s) | memory (MiB) | nearest | k = 10 | radius | |---|---|---|---|---|---|---|---| | 1 mps | 1,000,000 | 1 | 0.189 | 302 | 1.21 | 11.70 | 10.88 | | 2 mps | 125,000 | 8 | 0.135 | 103 | 1.92 | 9.01 | 8.04 | | 3 mps | 39,304 | 25 | 0.129 | 82 | 3.05 | 14.39 | 4.41 | | 4 mps | 15,625 | 64 | 0.130 | 92 | 4.38 | 19.67 | 6.43 | **1e7 points** | cell side | cells | objects per cell | build (s) | memory (MiB) | nearest | k = 10 | radius | |---|---|---|---|---|---|---|---| | 1 mps | 10,077,696 | 1 | 2.931 | 1,622 | 1.80 | 16.71 | 14.63 | | 2 mps | 1,259,712 | 8 | 1.487 | 876 | 3.24 | 15.75 | 14.21 | | 3 mps | 373,248 | 27 | 1.422 | 741 | 6.72 | 26.94 | 8.29 | | 4 mps | 157,464 | 64 | 1.459 | 701 | 9.88 | 33.20 | 9.74 | **Nearest neighbour is the one query that only gets worse as the cell grows,** by a factor of 3.6 at 1e6 and 5.5 at 1e7 from one mean separation to four. Its cost follows the objects per cell. **k-nearest is flat in the middle and worse at the ends,** best at two mean separations at 1e6 and near-flat from one to two at 1e7, then following the objects per cell like nearest neighbour. **A radius query is fastest on a coarse grid, with its best value in the middle:** 4.41 microseconds at three mean separations against 10.88 at one, and 8.29 against 14.63 at 1e7. What decides its cost is `rmax / cellsize` rather than either alone — **keep that ratio at or below 1** and the scan stays within the nearest cells; above it the scan widens sharply. **Memory falls by about half, not by the drop in cell count.** From one mean separation to four at 1e7 it goes 1,622 to 701 MiB while the cells fall 64-fold, because what remains is a floor that does not depend on the cell side: the grid's copy of the coordinates and its index tables. Past about three mean separations there is no further saving to have. ### Starting point - **Mixed use:** two to three mean separations. Nothing is at its worst there — at 1e7 it costs 3.24 to 6.72 microseconds on nearest neighbour, 15.75 to 26.94 on k = 10 and 8.29 to 14.21 on radius. - **Mostly nearest-neighbour work:** one mean separation, paid for in memory, 1,622 MiB at 1e7 against 741 at three. - **Mostly shell or radius queries:** three to four, keeping `rmax / cellsize` at or below 1. - **Memory-bound:** go coarser, but expect little further gain past three mean separations. Worth more than any of the above: run the sweep on your own data, which takes a few minutes. ``` python benchmarks/benchmark_cellsize.py --sweep ``` ## What to expect on other data **Clustered or masked catalogues will behave differently, and the cell-side guidance may not survive.** Every number here is uniform random points in a cube, which is the case a uniform grid suits best. A clustered catalogue, or a survey footprint with holes, fills some cells heavily and leaves others empty: queries in the dense regions test far more candidates than the objects-per-cell average suggests, while the empty cells still cost memory and still get walked. A tree adapts its subdivision to local density and a uniform mesh does not, so expect the comparison to move against meshsearch, and expect the best cell side to differ from the one suggested above. Sweep it. **Read the fine-grid memory figures as an order of magnitude.** At one mean separation the grid holds millions of small per-cell vectors, and the resident set depends on how the allocator grows them; it varies by hundreds of MiB for the same data. Size a machine from the coarse-grid rows, which are stable, and treat one mean separation at 1e7 as "one to two gigabytes" rather than a number. **Nothing here measures threads.** Const members may be called concurrently on one grid, and non-const members may not, but no figure above says what that scales like. **Nothing here measures a mixed or evolving workload.** Each table is one query kind on a grid built once and left alone. `addObject` and `removeObject` are not measured, nor is a grid queried after many of them. A grid that has churned heavily is not the grid measured here: the storage a removed object held is kept, and objects added later sit at the end of the coordinates rather than beside the others in their cell, which is where a freshly built grid gets some of its speed. **Nothing here measures anisotropic boxes or non-uniform units.** The cell is cubic and the same on all three axes. Data much longer in one dimension, or coordinates whose axes are not comparable, gets a cell side that is a compromise on every axis at once.