Skip to content

Speed up map matching up to 23x - #288

Open
zihaooo wants to merge 4 commits into
cyang-kth:masterfrom
zihaooo:pr/faster-matching
Open

Speed up map matching up to 23x#288
zihaooo wants to merge 4 commits into
cyang-kth:masterfrom
zihaooo:pr/faster-matching

Conversation

@zihaooo

@zihaooo zihaooo commented Sep 3, 2026

Copy link
Copy Markdown

Summary

Speeds up map matching by 13x to 23x on real-world data without changing the results. Most of the gain comes from the candidate search: a bounding box bug that made the rtree return most of the network for every point, a k-nearest search that skips the edges which cannot be among the k best, and an adaptive query box. The rest comes from an open addressing UBODT hash table, faster CSV parsing and writing, and reading the next block of trajectories while the current one is matched.

Depends on the toolchain PR opened alongside this one (this code uses std::from_chars / std::to_chars, which need C++17); the branch is stacked on it, so its commit shows up here as well until it is merged.

Benchmark

Test map matching of connected vehicle GPS points in three sub-regions in Michigan, with the settings k = 8, search radius 3000 m, GPS error 100 m, all output fields written. The time is the Time takes value reported by FastMapMatch.match_gps_file, i.e. reading the input CSV, matching and writing the result file, measured on an AMD Ryzen 9 9950X (16 cores, 32 threads) with 32 OpenMP threads.

Region Area (E-W x N-S) Road network GPS points Trajectories master (19ef34e) This PR Speedup
1 4.4 x 8.9 km 1,862 edges 5,368,249 75,595 13.0 s 1.0 s 13x
2 13.7 x 9.3 km 4,990 edges 15,387,122 150,458 72.7 s 3.1 s 23x
3 9.0 x 5.8 km 2,836 edges 8,020,427 92,083 34.5 s 1.6 s 22x
  • 19ef34e is current master, built with the newest tool chain it compiles with (GCC 13, Boost 1.74, GDAL 3.4.3, Python 3.10).
  • This PR is built with GCC 15, Boost 1.92, GDAL 3.12 and Python 3.14. The tool chain upgrade by itself did not change the timings (this PR built with the old tool chain runs region 1 in 1.07 s), so the gain is algorithmic.
  • Both versions produce the same matching results for these data sets (verified by comparing the complete output files).

What was slow

A gprof profile (single thread) showed 69 % of the time in linear_referencing, called about 1,750 times per GPS point: the exact point-to-edge distance was computed for most of the network for every point.

  1. Bounding box bug (ALGORITHM::boundingbox_geometry): the running maximum was initialised with DBL_MIN, which is the smallest positive double, not the most negative one. With negative coordinates (western or southern hemisphere) the max corner of every edge box stays at ~0, so all boxes extend to the prime meridian / equator and any query returns roughly half of the network. Fixed by initialising with -DBL_MAX. With positive coordinates the bug is invisible, which is probably why it was never noticed.
  2. Brute force after the rtree query (Network::search_tr_cs_knn): the exact distance was computed for every edge in the query box, then the k nearest were selected with partial_sort_copy. With a large search radius the box holds hundreds of edges even with correct boxes.

Changes

  • k-nearest candidate search: the k best candidates are maintained while iterating over the rtree result, and an edge is skipped when the distance from the point to its bounding box (a lower bound of the distance to the edge) already exceeds the current k-th best distance (plus a tolerance far above rounding error). candidate_compare is a total order (distance, then edge index), so the k best candidates and their order are unique and the result is exactly what partial_sort_copy produced.
  • Adaptive query box: the rtree is first queried with a box much smaller than the search radius; the box grows (x4) until k candidates are found whose k-th distance is smaller than the box half-width. Every edge outside the box is farther than that, so the result equals a full-radius query. The start size adapts to the previous point of the trajectory. The last round always uses the full radius, so points with fewer than k edges within the radius behave as before (their candidates are now sorted like all others).
  • UBODT: open addressing hash table with linear probing, records stored inline (one cache line per lookup) instead of buckets pointing to individually malloced chained records. Same "last inserted record for a key wins" semantics. UBODT::insert(Record *) copies the record; Record::next is kept for API compatibility but unused.
  • Result writer: std::to_chars instead of std::stringstream, reproducing the stream output exactly, including the setprecision(12) that operator<<(LineString) leaves on the stream for the fields written after pgeom / mgeom.
  • CSV point reader: fields converted with std::from_chars directly on the line instead of stringstream + getline + stof; any token from_chars does not fully accept falls back to std::stoi / std::stof, so values and exceptions are unchanged (coordinates are still parsed as float, as before).
  • match_gps_file: the next block of trajectories is parsed in a background thread (std::async) while the current block is matched; blocks of 10,000 trajectories and schedule(dynamic, 16). update_layer computes log(ep) once per candidate instead of once per candidate pair (same floating point result, same summation order).

Verification

  • fmm's output CSV is byte-identical between master and this PR for regions 1 and 2 above (20.7 M points), compared after sorting because the output order depends on thread scheduling. It is also identical between this PR built with the old and with the new toolchain.
  • Every change was checked separately with the same method while it was developed.
  • Compile-flag experiments (-march=native, compiling out trace logging) gave no measurable gain on top of these changes, so defaults are unchanged.

Also in this PR

Two small follow-up commits that are not performance related but touch the same code:

  • Statistics counted inside the critical section: total_points, total_trajs and progress in match_gps_file were incremented outside the omp critical block (only the first if statement was covered) and under-counted in multi-threaded runs. With the faster loop the under-count became clearly visible in the printed statistics. The same fix is part of the restart-on-disconnect PR opened alongside this one; whichever is merged second needs a trivial rebase of that block.
  • Reader fix for the last trajectory of a file: CSVPointReader::has_next_trajectory only looked at the stream, ignoring the line that read_next_trajectory buffers in prev_line when it hits the first point of the next trajectory. When that buffered line was the last line of the file, a trailing single-point trajectory was dropped, and the trajectory before it could be relabelled with the new id. has_next_trajectory now also reports the buffered line as pending, and the end-of-file case is detected from the loop state instead of the stream. Pre-existing bug, fixed here because the reader is changed anyway.
  • Progress output: the Progress N message printed every 1000 trajectories by fmm, stmatch and h3mm is now rewritten in place (\r + flush) instead of adding a line each time.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant