Benchmarking H3 Lookup Against an R-tree Query Jump to heading
A cell lookup is a dictionary access and a tree query is a traversal, so the benchmark appears settled before it starts. It is not, because the two indexes answer different questions and the work they push downstream differs by more than the lookup times differ.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
The two indexes are not interchangeable, and a benchmark that ignores that measures the wrong thing.
An R-tree answers “which geometries’ bounding boxes intersect this region”. The answer is a candidate set that must then be refined by an exact geometric test, because a bounding box overlap is not containment. The refinement is usually the larger cost.
An H3 index answers “which features are in these cells”. The answer is exact for the cells, and approximate for the region, because a set of hexagons only approximates any shape that is not made of hexagons. Refinement is needed at the boundary, and how much depends on the resolution chosen.
A fair comparison therefore measures total time to a correct answer, including the refinement each approach requires, and reports candidates examined alongside, because that number explains the timing rather than merely restating it.
The second fairness issue is the query shape. H3 is at its best when the query is itself a cell or a set of cells — an aggregation, a join on a grid. An R-tree is at its best for an arbitrary polygon or a nearest-neighbour search. Benchmarking only one shape answers only for that shape.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import time
from collections import defaultdict
from dataclasses import dataclass
import h3
import numpy as np
from rtree import index as rtree_index
from shapely.geometry import Point, Polygon, shape
from shapely.prepared import prep
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.index.benchmark")
@dataclass(frozen=True)
class Timing:
label: str
build_seconds: float
query_seconds: float
refine_seconds: float
candidates: int
results: int
@property
def total_seconds(self) -> float:
return self.query_seconds + self.refine_seconds
def build_rtree(points: np.ndarray) -> tuple[rtree_index.Index, float]:
started = time.perf_counter()
def stream():
for i, (x, y) in enumerate(points):
yield (i, (x, y, x, y), None)
tree = rtree_index.Index(stream())
return tree, time.perf_counter() - started
def build_h3(lonlat: np.ndarray, resolution: int) -> tuple[dict, float]:
started = time.perf_counter()
cells: dict[str, list[int]] = defaultdict(list)
for i, (lon, lat) in enumerate(lonlat):
cells[h3.latlng_to_cell(lat, lon, resolution)].append(i)
return dict(cells), time.perf_counter() - started
def query_rtree(tree, points: np.ndarray, polygons: list[Polygon]) -> Timing:
query_seconds = refine_seconds = 0.0
candidates = results = 0
for polygon in polygons:
started = time.perf_counter()
hits = list(tree.intersection(polygon.bounds))
query_seconds += time.perf_counter() - started
candidates += len(hits)
# Refinement is NOT optional: a bounding box hit is not containment.
started = time.perf_counter()
ready = prep(polygon)
results += sum(1 for i in hits
if ready.contains(Point(points[i][0], points[i][1])))
refine_seconds += time.perf_counter() - started
return Timing("r-tree", 0.0, query_seconds, refine_seconds, candidates, results)
def query_h3(cells: dict, lonlat: np.ndarray, polygons: list[Polygon],
resolution: int) -> Timing:
query_seconds = refine_seconds = 0.0
candidates = results = 0
for polygon in polygons:
started = time.perf_counter()
# Cover the polygon with cells; this is the approximation step.
covering = h3.geo_to_cells(polygon.__geo_interface__, resolution)
hits: list[int] = []
for cell in covering:
hits.extend(cells.get(cell, ()))
query_seconds += time.perf_counter() - started
candidates += len(hits)
# Cells that lie wholly inside need no refinement; boundary cells do.
started = time.perf_counter()
ready = prep(polygon)
results += sum(1 for i in hits
if ready.contains(Point(lonlat[i][0], lonlat[i][1])))
refine_seconds += time.perf_counter() - started
return Timing(f"h3 r{resolution}", 0.0, query_seconds, refine_seconds,
candidates, results)
def report(timings: list[Timing]) -> None:
for t in timings:
logger.info("%-10s query %6.3fs + refine %6.3fs = %6.3fs "
"%8d candidate(s) -> %7d result(s) (%.1fx selectivity)",
t.label, t.query_seconds, t.refine_seconds, t.total_seconds,
t.candidates, t.results,
t.candidates / max(1, t.results))
# Identical results are the precondition for comparing anything else.
distinct = {t.results for t in timings}
if len(distinct) > 1:
logger.error("indexes returned different result counts %s — the "
"comparison is meaningless until they agree", distinct)
if __name__ == "__main__":
logger.info("run with a real workload; random boxes favour whichever "
"index the box shape happens to suit")
Step-by-step walkthrough Jump to heading
- Time the refinement separately. An index query that returns quickly and hands back ten times as many candidates has not won; separating the two numbers is what makes that visible.
- Use a prepared geometry for the refinement. Preparing the polygon once per query rather than per candidate removes a cost that would otherwise dominate and distort the comparison.
- Count candidates alongside time. The candidate count is the structural measurement and it explains the timing; without it, a result is a number with no mechanism behind it.
- Report selectivity. Candidates divided by results says how much work each index wasted, which is the number that transfers to a different machine.
- Assert the results agree. Two indexes returning different counts are answering different questions, and every other comparison is meaningless until that is resolved.
- Cover the polygon at query time for the cell index. The covering step is part of the query cost and omitting it from the timing flatters the cell index substantially.
- Use a real workload. Randomly generated query boxes favour whichever index the box shape suits, which is a property of the benchmark rather than of the application.
Verification Jump to heading
- Result counts match exactly. Any difference means the two are not answering the same question.
- Selectivity is reported. A ratio near one means the index is doing nearly all the work; a large ratio means the refinement is.
- Query shape is varied. Run compact and elongated polygons; the candidate advantage should move between the indexes.
- The cell resolution is swept. Too coarse and the covering is inaccurate; too fine and the covering itself becomes expensive.
- Build time is reported but separated. It matters for a one-shot job and not at all for a long-lived index.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Cell index looks unbeatable | Covering excluded from the query timing | Time the covering as part of the query |
| R-tree looks unbeatable | Refinement excluded from the timing | Time the exact test as part of the answer |
| Results differ between indexes | Different predicates applied | Assert equal result counts before comparing |
| Refinement dominates everything | Polygon prepared per candidate | Prepare the geometry once per query |
| Benchmark does not transfer | Random query boxes used | Benchmark the application’s real query shapes |
| Cell index slow at fine resolution | Covering cost grows with cell count | Sweep resolutions and report the curve |
| Conclusions reverse on other data | Single distribution tested | Test both clustered and dispersed data |
Specification reference Jump to heading
H3 assigns each location to a hexagonal cell at a chosen resolution, so a containment query is a set membership test over the cells covering the query region, with approximation error at the boundary. An R-tree returns entries whose bounding rectangles intersect the query rectangle, which is a necessary but not sufficient condition for geometric containment, so an exact test must follow. See the H3 documentation for the covering operations and Spatial Index Selection: R-tree vs H3 vs Quadkey for the wider comparison.
Frequently Asked Questions Jump to heading
Why is comparing index lookup times alone misleading?
Because neither index returns a final answer. An R-tree returns bounding-box candidates that must be tested exactly, and a cell index returns cell members that must be tested near the region boundary. The refinement is frequently the larger cost, so a benchmark that stops at the index lookup can report a winner that the total time contradicts. Measuring time to a correct answer is the only comparison that transfers.
Does the query shape really change the outcome?
Substantially. A compact polygon is covered efficiently by hexagons and matches an R-tree’s bounding box loosely, favouring the cell index. A long thin diagonal polygon is covered by many hexagons of which most are nearly empty, and its bounding box is enormous relative to its area, which hurts both — but differently. Benchmarking one shape and generalising is the most common way these comparisons mislead.
What cell resolution should the benchmark use?
Several, reported as a curve. Too coarse and the covering approximates the query region badly, inflating candidates. Too fine and the covering itself becomes expensive, because the number of cells grows quickly with resolution. The best resolution depends on the size of the query regions relative to the cells, so a single value answers only for the region size it was chosen against.
Should build time be included in the comparison?
Reported separately, and weighted by how the index is used. For a long-lived index queried millions of times, build cost is irrelevant and query cost is everything. For a one-shot join where the index is built and discarded, build cost can dominate and a cell assignment — a single arithmetic operation per feature — is hard to beat. Stating which regime applies is part of the result.
Related Jump to heading
- Spatial Index Selection: R-tree vs H3 vs Quadkey — the parent topic and the qualitative comparison.
- Bulk Loading an R-tree Versus Inserting One by One — making the tree side as fast as it can be before comparing.
- Choosing H3 Resolution for OSM Point Aggregation — picking the resolution this benchmark sweeps.
- Accelerating Point-in-Polygon Joins on OSM Data — the workload this measures.
- Choosing a Grid Cell Size for OSM Spatial Hashing — a third structure worth including in the sweep.
Up one level: Spatial Index Selection: R-tree vs H3 vs Quadkey.