Skip to content

Tracking: performance gaps from Geoawesome's QGIS benchmark (buffer, raster, drawing) #1227

Open

Description

@giswqs

Geoawesome's benchmark of RustGIS 0.4.0 against QGIS 3.34 and plain Python (Sebastian Walczak, 31 October 2026) found fast start-up, file input and output, and scripting, but slower buffering, raster processing and drawing. This issue tracks those gaps. The article doesn't link its notebook, so its numbers below can't be rerun yet. It used Windows, with 16 logical cores and 33.6 GB of RAM.

Test RustGIS 0.4.0 QGIS Python Status on main
Buffer 100,000 points by 5 km, GeoPackage to GeoPackage 11.9 s 6.98 s 3.92 s Likely cause found, step 1
Slope, 8.9 million cell DEM 4.03 s 0.82 s 1.41 s #1130 landed after 0.4.0, not re-measured
Draw 100,000 buildings, zoomed to 4 × 3 km 0.89 s 0.26 s 0.47 s #1065
Render a DEM from a COG instead of a plain GeoTIFF about 1.1× faster about 1.6× faster not given Step 3
IDW, 100,000 points, 0.01° grid refused as "too much work" not given not given Step 4

Plan

1. Quadratic Dataset::push (small, do first)

The article found buffer time growing about 26× from 10,000 to 100,000 features and placed the cause in the buffer step. Dataset::push (crates/data/src/lib.rs) scans every feature for the largest id on each insert, so building an output of n features makes about n²/2 comparisons. push_next already avoids this for readers, and its comment names the problem. buffer in crates/analysis/src/tools.rs calls push once per feature.

Timing gp.run buffer alone with the PyPI 0.4.0 wheel (Linux, one run each, points read from GeoJSON, distance 5):

Features Time
1,000 0.004 s
10,000 0.04 s
30,000 0.35 s
100,000 8.81 s

100,000 features means about 5 billion comparisons, which fits that time, but no patched build has confirmed it yet. Other tools that build their output with push should scale the same way.

  • Stop push scanning for tool outputs, keeping ids unique when a dataset's ids aren't in order
  • Add a test or benchmark that catches quadratic growth, and re-measure Buffer

2. Raster speed (already planned in #1111)

The article found slope time proportional to raster size, but five to seven times slower than GDAL at every size.

3. Drawing what's in view

  • Read only the view's features when drawing a layer read in place: [Feature]: Layers read in place are read and projected in full twice on every render #1065. The draw pass still calls visit_chunks(None, …) through visit_complete (crates/render/src/lib.rs).
  • Measure the same zoomed view on a loaded layer, since the article doesn't say which kind of layer it drew.
  • Draw local GeoTIFF raster layers from the overview that suits the zoom. geotiff::read only picks an overview when the image won't fit under MAX_SAMPLES. It doesn't pick one for the zoom, which may explain the small COG gain.

4. IDW on large grids

  • Decide whether MAX_IDW_WORK (400 million cell-point pairs in crates/analysis/src/rasterops.rs) should stay. The refusal is explicit and RustGIS was fastest at 0.02° (7.08 s against 9.30 s for QGIS), so this is about reach rather than speed. Options are a search radius or an index that lets each cell skip distant points.

5. Follow-up

Out of scope

The article's other limits are product decisions and aren't speed work: no full PROJ for vectors, no versioned or multi-user editing, and the size of the plugin registry.

Related

#1111 covers large data and hillshade and GeoTIFF writing, #1142 Clip and Select by Location, #1067 drawing on several cores, #1047 lag on big data, and #1057 per-layer caching.

Activity

  1. pick2510 commented on Nov 1, 2026

    @pick2510
    Contributor

    I'll take step 1 (the quadratic Dataset::push).

    I reproduced it first with synthetic data. The input was random points over Switzerland as GeoPackages, buffered by 5 km, GeoPackage to GeoPackage. RustGIS was main at 1672a54, release CLI, input in lon/lat. QGIS was 4.2.3 (flatpak), native:buffer timed inside one QGIS Python process, input in UTM 32N, 5 segments. The machine is a Xeon Silver 4114 on Linux, two runs each (they agreed within a few percent).

    Points RustGIS buffer RustGIS, push without the id scan QGIS, incl. read and write
    1,000 0.11 s 0.11 s 0.05 s
    10,000 1.04 s 1.02 s 0.34 s
    30,000 3.89 s 3.04 s 1.1 s
    100,000 24.2 s 10.4 s 5.8 s

    The RustGIS columns subtract start-up and reading (0.03 to 0.1 s). Writing the 100k output takes about 0.9 s more. GeoJSON input gives the same times. The "without the id scan" column is a throwaway build where push takes the last id plus one, only to confirm the cause.

    • The id scan is what makes Buffer grow faster than linearly. Without it, 100k points take 10.4 s and the growth is linear.
    • RustGIS is still about 1.8× QGIS's time after that, at roughly 0.1 ms per point. That part comes from the geodesic buffer: each vertex comes from GeographicLib on WGS 84, straight from lon/lat, where QGIS buffers flat in a projected CRS. RustGIS also makes about 33 vertices per circle, against QGIS's 21. That's a separate question from step 1.
    • I measured about 1 s at 10k where your table has 0.04 s with the 0.4.0 wheel. The geodesic buffer was already in 0.4.0, so I don't know yet where the difference comes from.

    Plan for the fix: keep the largest id in Features alongside the rows. push updates it, and any other mutable access to the rows clears it, so the next push scans once. Ids stay unique when they aren't in order, and none of the ~44 push callers change. I'll add a test that catches quadratic growth and re-measure Buffer.

  2. pick2510 commented on Nov 1, 2026

    @pick2510
    Contributor

    Update on Buffer. Step 1 landed in #1245, and #1248 takes on the rest of the gap.

    #1245 (merged): Features keeps the largest id, so Dataset::push no longer searches every feature. Buffer on 100,000 points went from 24.2 s to 10.1 s.

    #1248 (open): almost all of the remaining time was geodesic solves, about 160 for each point's 33-vertex circle. The PR makes three changes:

    • Each vertex goes back to lon/lat once.
    • A bound on how far an edge can stray, by its latitude and its distance from the plane's centre, skips the probe solves wherever they can't find anything.
    • Buffer works on several threads, taking features in batches of 16.

    The output is byte for byte the same as main, compared as WKB on 30,000 points and on a global mix of points, lines and polygons at 0.5, 5, 200, 2,000 and 5,000 km.

    Same setup as above: random points, buffered by 5 km, GeoPackage, release CLI, Xeon Silver 4114 with 40 threads, load included, best of two runs. QGIS is 4.2.3 native:buffer in UTM 32N, read and write included.

    Points 0.4.0-era main main with #1245 #1248, one core #1248, 40 threads QGIS 4.2.3
    1,000 0.11 s 0.13 s 0.047 s 0.032 s 0.05 s
    10,000 1.04 s 1.04 s 0.24 s 0.06 s 0.34 s
    30,000 3.89 s 3.06 s 0.65 s 0.15 s 1.1 s
    100,000 24.2 s 10.1 s 2.09 s 0.32 s 5.8 s

    So with both, Buffer on 100,000 points is about 75 times faster than before. On one core it is about 2.8 times faster than QGIS, and on 40 threads about 18 times faster. The browser build stays on one thread.

    Writing the 100,000-feature GeoPackage takes about 0.9 s more on top. That is now the larger part, and it isn't covered by either PR.

  3. pick2510 commented on Nov 1, 2026

    @pick2510
    Contributor

    Since #1241 merged, the article's Buffer test no longer runs: Buffer refuses it. #1241 brings two changes:

    1. Finer arcs: a point's circle has 128 edges instead of 32, and line caps 64, so buffers meet the reference tests' 0.1% area tolerance.
    2. A vertex budget: Buffer and Select by Location keep at most 2,000,000 output vertices in total. They are counted across all features, before any dissolve. Past that the tool stops with "Buffer exceeds its vertex budget; split large features or select fewer features".

    A 128-edge circle has 129 coordinates, so the budget allows about 15,500 point buffers, as #1241's description says. Measured on main at 95376b7 (random points, 5 km, release CLI):

    Points Result
    10,000 buffered
    15,000 buffered
    30,000 "Buffer exceeds its vertex budget"
    100,000 refused (more than 30,000)

    Before #1241, with #1245 and #1248, 100,000 points took 0.32 s on 40 threads and 2.1 s on one core, against QGIS 4.2.3's 5.8 s (table above). RustGIS 0.4.0 took 11.9 s in the article. The 4× vertices also cost time below the limit: 10,000 points on one core went from 0.24 s to 0.72 s. Those two figures come from different builds, so they are approximate.

    The budget looks stricter than memory needs:

    • 2,000,000 vertices is about 32 MB of coordinates. 100,000 point buffers would be about 200 MB, which a desktop handles easily.
    • Without dissolving, each feature's buffer is made on its own and has to be in the output anyway, so memory grows linearly with the features.
    • The real memory risks are Dissolve All, which feeds everything into one union, and a single huge feature.

    Some options:

    • Apply the budget to Dissolve All and to each feature on its own, not to the total of a plain per-feature buffer.
    • Or raise it a lot, or scale it with the memory available.
    • Separately, choose the number of edges from the distance and the tolerance, so small buffers don't always carry 128.

    Which way to go is a call for #1241's design, so I'm only reporting it here. #1254 (step 1 of #1252) keeps the budget exactly as it is.

  4. Mavrick-F commented on Nov 2, 2026

    @Mavrick-F
    Member

    Thanks for reproducing this and spelling out the regression. I’ll take the buffer-budget follow-up: keep the finer arcs and conservative per-feature/dissolve limits, give ordinary buffers a byte budget, and process them in bounded batches. I’ll check the 100,000-point case and peak memory, including the distance-selection path.

  5. Mavrick-F commented on Nov 2, 2026

    @Mavrick-F
    Member

    #1305 is merged. Ordinary Buffer now uses a retained-storage byte budget separately from per-feature complexity, restoring the 100,000-point workload while keeping the finer arcs. Dissolve All retains its cumulative vertex guard. Distance selection also accounts for retained storage and bounds actual candidate checks for larger selector sets, preserving small-set and zero-distance behavior. The PR includes the reproduction, resource measurements and validation results.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions