Skip to content

r.proj: memory-bounded parallel banding - #7627

Open
krcoder123 wants to merge 23 commits into
OSGeo:mainfrom
krcoder123:gsoc-rproj-banding
Open

r.proj: memory-bounded parallel banding#7627
krcoder123 wants to merge 23 commits into
OSGeo:mainfrom
krcoder123:gsoc-rproj-banding

Conversation

@krcoder123

@krcoder123 krcoder123 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

This PR parallelizes r.proj with OpenMP while keeping memory use within the amount the user allows.

How it works

The output is split into horizontal bands. For each band, the code projects the band's edges back into the input to see which input rows it touches, then picks a band height where that input strip fits under the memory cap. For easy maps like lat/lon to Web Mercator (EPSG:4326 to EPSG:3857), this is all that's needed, since each output band only touches a small thin part of the input.

Hard maps are pairs where the output rows don't line up with the input rows. For example, lat/lon to Lambert Azimuthal Equal Area over a wide European extent (EPSG:4326 to EPSG:3035). There a single full width output row can need more input rows than the cap allows. When that happens the band is split into column tiles until each tile's input fits. Pole maps like lat/lon to Polar Stereographic North (EPSG:4326 to EPSG:3413) with the pole inside the frame are the extreme case, since every longitude converges at the pole. A tile that has the pole gets its input span extended to cover the pole row.

If no tile width can bring the input under the cap, the module still won't fail. It warns the user with the exact memory value that they would need to allow to keep the parallel path, and still finishes the run through the old serial tile cache. This is so every job the old r.proj could handle can still complete here. In my benchmark sweeps this fallback never activated on any real projection pair down to a 1 MB cap, only on an input that is purposely built wide enough to trigger it. So this case is very rare and most maps would fall under the easy, hard or pole category mentioned above.

Once the bands have their rows, the work is run in parallel. Input rows are read with per thread file descriptors, and each thread computes with its own PROJ context through the new GPJ_clone_transform API in lib/proj. While a band computes, the previous band's output is written at the same time. The write itself has to stay serial because compressed row offsets depend on all prior rows, but it doesn't block the compute threads while it runs. Consecutive bands also need many of the same input rows, so the input strip is kept in memory and slides forward from band to band. It only reads the new rows instead of re-reading the whole strip from disk.

Correctness

Output is bitwise identical to serial r.proj across 33 comparisons. It's all seven interpolation methods on a 100M cell polar pair (4326 to 3413) at two thread counts, all seven methods again through the forced fallback path, and nearest, bilinear, and lanczos on the easy (4326 to 3857) and hard (4326 to 3035) pairs at two thread counts. Null cells match exactly. Parallel correctness pytest tests are included in this PR. The method reference tests and testsuite migration are in #7766, and a fix for a data race on globals in lib/proj is in #7764.

One thing I want to mention regarding behavior. For integer inputs above 2^24 with "nearest" method the parallel output can differ from serial by 1 and the parallel value is the correct one. For example an input cell of 16777217 comes out of old r.proj as 16777216 but stays 16777217 here, because the old code caches inputs as float32 which cannot hold integers that large exactly. Below 2^24 the two are identical.

Performance (The machine I used and tested all this on is an 8 core Apple M3)

At 8 threads I got 4.4x to 4.9x on the easy pair, 4.2x to 4.9x on the polar pair, and 2.1x to 3.5x on the hard pair depending on the memory cap. Single thread runs equally with serial. Peak RSS on the easy pair drops from 763 MB with the whole map in RAM to 130 MB at memory=50. Speedup ratios also grow as the memory cap increases because taller bands mean fewer repeated input reads and less serial sizing work. So, users can trade memory for speed with the existing option. A benchmark script to show thread scaling is a part of this PR under raster/r.proj/benchmark.

Current tables and graphs are in the most recent comments all the way down below.

@krcoder123 krcoder123 changed the title r.proj: memory-bounded parallel banding (draft) r.proj: memory-bounded parallel banding Jul 2, 2026
@github-actions github-actions Bot added raster Related to raster data processing C Related code is in C module labels Jul 2, 2026
Comment thread raster/r.proj/main.c Outdated
Comment thread raster/r.proj/main.c
G_malloc((size_t)band_orows * outcellhd.cols * cell_size);

double t1 = omp_get_wtime();
#pragma omp parallel

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why nested parallelism? How is it supposed to work?

https://docs.oracle.com/cd/E19205-01/819-5270/aewbc/index.html

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't use nested parallelism here. The band loop is serial, and each band runs one parallel region. The omp for inside it does not create a second team, it just splits the loop rows among the threads that omp parallel already made. So it is one level of threads, same as writing parallel for, just split into two directives. I split them because each thread has to clone its own PROJ context before the loop and destroy it after, and that setup has to live inside the parallel region but outside the for. I also grepped the functions called inside the region (GPJ_transform, the strip reader) and none of them open their own parallel regions.

Also because the region sits inside the band loop, the team and the PROJ clones get recreated every band instead of once. The runtime reuses its thread pool so this is cheap, and in the benchmark it gets absorbed into the compute phase, which still scales 5.2x. But if you would rather have the region put above the band loop so everything is created all at once I can do that too.

Comment thread raster/r.proj/main.c Outdated
double t0 = omp_get_wtime();
G_switch_env(); /* -> input */
for (int r = imin; r <= imax; r++)
Rast_get_row(fdi,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see if it's possible to parallelize reading.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think reading on a single file descriptor is thread safe. The read path keeps descriptor's state so reads happening at the same time can cause a race. But every thread having its own descriptors like r.neighbors should work. I will try that and benchmark it against the serial load.

@krcoder123 krcoder123 Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried the per thread descriptor approach like r.neighbors and it works. Each thread opens its own descriptor and reads a disjoint block of the band's strip. The read phase went from about 0.50s to 0.26s at 8 threads, so about 1.9x. It doesn't scale fully linearly because the reads share one SSD, and the curve flattens like the SSD is saturating, not like a fixed cost. But a better device may scale a bit more linearly. Regardless the total is now about 3.0x vs serial. So the overall speedup has some improvement because the input read is parallelized. I updated the table in the PR description with the new numbers, all measured with the same LZ4 compression including the serial reference. I also added a "Input read speedup" column.

@HuidaeCho

HuidaeCho commented Jul 2, 2026

Copy link
Copy Markdown
Member

@krcoder123 In your result table, please add a new column for Compute speedup since this module is write-dominant.

Comment thread raster/r.proj/main.c Outdated
Comment on lines +107 to +108
/* Inside the map but outside the loaded strip: the span check under-sized
* the strip. This is a correctness failure, not a NULL. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure what you mean here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check above it already handles coordinates that fall outside the input map, those become NULL like normal. This second check catches an input row that is inside the map but not a part of the rows we preloaded for this band. That should be impossible if the band's footprint estimate was right, so if it ever triggers it means the estimate was wrong. That is a bug in the band sizing, so the module fails with an error instead of writing a NULL and silently producing wrong output. I re wrote the comment to make it clearer now.

@HuidaeCho

HuidaeCho commented Jul 2, 2026

Copy link
Copy Markdown
Member

This method only works for when the transformations are relatively close by.

Does it mean the current implementation only works for +-1 rows? +-15% of the entire map, you said.

Please consider a fallback parallelization method. Row-band then column-wise parallelization. If this performs comparably or even faster, I would only use this method. If this is slower than your current row-wise parallelization, the column-wise method as a fallback.

@krcoder123

Copy link
Copy Markdown
Contributor Author

@krcoder123 In your result table, please add a new column for Compute speedup since this module is write-dominant.

Added a compute speedup column in between "Compute (s)" and "Output write (s)".

@krcoder123

krcoder123 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

This method only works for when the transformations are relatively close by.

Does it mean the current implementation only works for +-1 rows? +-15% of the entire map, you said.

Please consider a fallback parallelization method. Row-band then column-wise parallelization. If this performs comparably or even faster, I would only use this method. If this is slower than your current row-wise parallelization, the column-wise method as a fallback.

I measured the input row span for every output band across a few CRS pairs. The current banding works fine for pairs near 0% like 4326 to 3857 and about 3 to 7% for UTM or LAEA. The problem is polar transforms, where one output row can touch 50 to 80% of the input rows and no band fitting the memory cap covers that. This is why a fallback approach is needed.

The column-wise idea sounds interesting. I will benchmark it against the row-wise method and let you know what I find.

@github-actions github-actions Bot added the CMake label Jul 8, 2026
Comment thread raster/r.proj/main.c Outdated
* read-only shared; the static METERS_in/out race is benign
* (constant CRS per run). */
struct pj_info tproj_local = tproj;
PJ_CONTEXT *thread_ctx = proj_context_create();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably create wrappers in gproj library instead of calling proj library directly. (That would also make the dependency changes in Makefile and CMakeLists.txt unnecessary).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion. I implemented this in 98d43be. I moved the context handling for each thread into gproj as GPJ_clone_transform and GPJ_free_transform_clone. The direct PROJ dependency lines in the Makefile and CMakeLists are removed since r.proj now gets PROJ transitively through libgrass_gproj.

@krcoder123

krcoder123 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

This method only works for when the transformations are relatively close by.

Does it mean the current implementation only works for +-1 rows? +-15% of the entire map, you said.
Please consider a fallback parallelization method. Row-band then column-wise parallelization. If this performs comparably or even faster, I would only use this method. If this is slower than your current row-wise parallelization, the column-wise method as a fallback.

I measured the input row span for every output band across a few CRS pairs. The current banding works fine for pairs near 0% like 4326 to 3857 and about 3 to 7% for UTM or LAEA. The problem is polar transforms, where one output row can touch 50 to 80% of the input rows and no band fitting the memory cap covers that. This is why a fallback approach is needed.

The column-wise idea sounds interesting. I will benchmark it against the row-wise method and let you know what I find.

I implemented the row band then column-wise parallelization you suggested.

The way it decides whether to do column wise or row wise is before loading anything, it checks if the next band of output rows can be processed with its input staying under the memory cap. If yes at full width, then we can go ahead with row wise, which is how it was even before. But now, if even a single full width row needs more input than the cap allows, the band gets split into column tiles until each tile's input fits. This is the column-wise parallelization. The only case that still doesn’t work is a tile whose input footprint contains the pole point, since every longitude converges there, so no tile width brings its footprint under the cap. This would be the North and South Poles. Column-wise works for every other CRS pair though. Output is the exact same against serial r.proj on an easy pair (no tiling), LAEA (250 tiles) and Albers (492 tiles), at 1 to 8 threads with both test input patterns.

On easy pairs the speed is comparable to the current row-wise code (back to back comparisons, within noise, same code path when tiling does not engage). Based on what you said initially, would it make sense to make column-wise the primary method?

For the tilted CRS pairs I tested with a wide extent LAEA job. Previously the row wise approach didn't work on this at memory=50. With column tiling the same job finishes nicely. For a serial reference I ran the old serial r.proj on the same dataset. It takes 10.24 s but needs the whole input held in RAM, so it ran with memory=2000 while the tiled runs used memory=50.

The table in this comment is a harder tilted pair compared to the pair in the PR description, EPSG:4326 to EPSG:3035 (lat/lon to Lambert Azimuthal Equal Area over a wide European extent)

Threads Sizing (s) Input read (s) Input read speedup Compute (s) Compute speedup Output write (s) Total (s) Speedup vs serial (10.24 s)
1 2.60 1.96 1.00x 9.85 1.00x 0.82 15.23 0.67x
2 2.60 1.63 1.20x 5.13 1.92x 0.83 10.19 1.00x
4 2.61 1.36 1.44x 2.63 3.75x 0.84 7.44 1.38x
8 2.61 1.31 1.50x 1.83 5.38x 0.83 6.58 1.56x

At 8 threads it is 1.56x faster than the whole map serial using a 1/40th of the memory, with compute scaling 5.38x. The remaining serial cost is the fit search (2.61 s) plus the write. The search can come down more, since adjacent bands almost always need the same tile width. So working on that bring down that sizing time is my next task. Write cannot be parallelized just like before.

@krcoder123

Copy link
Copy Markdown
Contributor Author

I also wanted to clarify how I verified the outputs more clearly. I subtracted the tiled output map from the serial output map in GRASS and check that the difference is 0 everywhere, meaning both min and max of the difference map are 0. I also checked that the null cells are in the exact same places and that both maps have the same number of cells. I did this at 1, 2, 4 and 8 threads with two different test inputs. The md5 part is just for the binaries, I hash them so I always know exactly which build produced which benchmark number since I have to try multiple different maps. Sorry for the confusion.

@echoix

echoix commented Jul 14, 2026

Copy link
Copy Markdown
Member

@krcoder123 Please avoid using CI time for a draft PR, it doesn’t need to be kept updated with every single merged PR, especially when the CI queue is a bit overloaded (that would mean exponential CI usage on each merged PR). We usually don’t mind when it’s combined with actual real work or a real chance of semantic conflicts (logic would break when combined to other changes even if textually it merges fine)

@krcoder123

Copy link
Copy Markdown
Contributor Author

@krcoder123 Please avoid using CI time for a draft PR, it doesn’t need to be kept updated with every single merged PR, especially when the CI queue is a bit overloaded (that would mean exponential CI usage on each merged PR). We usually don’t mind when it’s combined with actual real work or a real chance of semantic conflicts (logic would break when combined to other changes even if textually it merges fine)

Understood, sorry about that. The update branch I clicked were on PR 7440, I'll stop doing that until review starts there. The push for this draft PR was fixing a bug and adding some new stuff too.

@krcoder123

krcoder123 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Just an update on the pole handling and benchmarks.

The most recent commit can now handle pole maps with a north or south pole inside the output frame, which previous versions couldn’t do. The reason it couldn’t before is because of a bug in how each tile estimates which input rows it needs. The estimate only checked the tile's edges, but for a tile that contains the pole, the northern most input row it needs sits in the middle of the tile, not on the edge. So the code loaded too few rows and stopped with an error. The fix now finds where the pole lands in the output once per map, and when a tile contains it, extends that tile's row range to cover it.

The column-wise parallelization can now handle easy, hard and very tough maps like pole maps. They all match serial r.proj output exactly, tested at memory from 2 MB to 50 MB at 1 and 8 threads. I also brought the sizing portion from 2.6 s to 1.6 s on a hard map by reusing the previous band's size between bands.

Here are some of the benchmarks I did:

Easy map: EPSG:4326 to EPSG:3857 (lat/lon to Web Mercator), 105M output cells, memory=50, serial baseline 7.503 s

Threads Sizing (s) Input read (s) Input read speedup Compute (s) Compute speedup Output write (s) Total (s) Speedup vs serial (7.503 s)
1 0.0510 0.4988 1.00x 5.5136 1.00x 0.9288 7.467 1.00x
2 0.0523 0.4858 1.03x 3.0737 1.79x 0.9249 5.026 1.49x
4 0.0529 0.3725 1.34x 1.6921 3.26x 0.9397 3.534 2.12x
8 0.0525 0.2571 1.94x 1.0286 5.36x 0.9242 2.758 2.72x

Hard map: EPSG:4326 to EPSG:3035 (lat/lon to Lambert Azimuthal Equal Area, wide European extent), 76M output cells, memory=50, serial baseline 11.537 s

Threads Sizing (s) Input read (s) Input read speedup Compute (s) Compute speedup Output write (s) Total (s) Speedup vs serial (11.537 s)
1 1.0541 5.4805 1.00x 9.6336 1.00x 0.6792 17.695 0.65x
2 1.1016 3.0268 1.81x 5.2422 1.84x 0.6947 11.019 1.05x
4 1.0820 1.6646 3.29x 2.9569 3.26x 0.7052 7.228 1.60x
8 1.0856 1.2984 4.22x 1.7452 5.52x 0.6960 5.683 2.03x

Pole map: EPSG:4326 to EPSG:3413 (lat/lon to Polar Stereographic North, north pole inside the frame), 100M output cells, memory=50, serial baseline 29.252 s

Threads Sizing (s) Input read (s) Input read speedup Compute (s) Compute speedup Output write (s) Total (s) Speedup vs serial (29.252 s)
1 1.5298 4.3008 1.00x 28.4095 1.00x 0.9365 36.035 0.81x
2 1.5671 2.3615 1.82x 14.9162 1.90x 0.9553 20.741 1.41x
4 1.5884 1.3080 3.29x 7.8141 3.64x 0.9572 12.647 2.31x
8 1.5773 1.0157 4.23x 4.8333 5.88x 0.9560 9.302 3.14x

The one thing I want to make clear is that for hard maps and pole maps while the parallelization is good, N=1 becomes slower than serial r.proj. This is because of how the memory option works. This code splits the output into horizontal bands so that only a slice of the input has to be in memory at a time, and neighboring bands need some of the same input rows, so those rows get read from disk more than once. A single thread has to do all of that re-reading alone, while serial r.proj reads the whole input into RAM once and never re-reads. But I did verify the slowdown is purely the memory cap and not the parallel code. With the memory option set high enough, N=1 matches serial (11.28 s vs 11.54 s on the hard map) and every other thread count gets faster too. For example I verified that 8 threads goes from 2.0x to 3.5x. So users can trade memory for speed with the existing memory option if they wanted to.

One note on the tables, my original hard map test dataset was lost, so the table in this comment uses a rebuilt 4326 to 3035 dataset of the same dimensions with a fresh serial reference. Absolute times are not comparable to the earlier table but the scaling behavior is the same.

I used Claude (Fable) to help with testing and benchmarking throughout, all results verified against serial output. it also helped me find some logic errors which are now fixed.

I am away for a few days but I'll keep an eye on this PR. But I wanted to ask if you think a fallback method like tile cache is still needed given that this parallelization method can handle poles now, and if so for which cases? Thanks

@petrasovaa

Copy link
Copy Markdown
Contributor

I am posting the AI suggestion about the implementation structure. Lot of stuff to sort out, hopefully it will make more sense to you when you are familiar with the code base. Couple comments:

  • Uncovered bug in do_proj.c and fixed v.proj: fix reprojection with smax=0 from latlon #7730 (currently used only in v.proj, but suggestion 5 explored the prossibility of using the function)
  • number 6 (globals in library) - these improvements should probably go into a separate PR, that would reduce the size of this PR
  • number 5 seems particularly important, would that benchmarking make sense?
  • number 3 seems applicable to other tools, so maybe good to try, but not necessarily specific to r.proj
  • see if current testsuite is sufficient, it might be better to rewrite it to pytest, it would be more suitable for testing the different CRSs
Details 1. Keep the input resident across bands (rolling window)

Problem it fixes: the N=1 regression on hard maps (0.65x). Each band's strip is malloc'd, read, and freed. Adjacent bands need overlapping input rows — on the 3035 map enough overlap that input read is 5.5 s where old serial r.proj spent ~0.5 s reading each row exactly once.

How: bands are processed top-to-bottom, and for any continuous transform the input row span advances mostly monotonically with the output row. So instead of malloc → read [imin, imax] → free, keep the strip as a sliding window: when band N+1 needs [imin', imax'] and the buffer holds [imin, imax], memmove the overlap down and read only rows (imax, imax']. Same memory cap (the window is never larger than one band's strip), no new data structure.

Impact: each input row is decompressed roughly once for row-banded cases, which should bring N=1 back to parity with serial 8.4. That matters because a parallel rewrite that's slower at defaults than the code it replaces is a hard sell, and r.proj is typically a run-once import step.

Cost: small — a memmove and bookkeeping. Doesn't help the column-tiled case (next item).

  1. Keep the tile cache (readcell) as the residency layer for the scatter case

Problem it fixes: column tiles have structurally unbounded read amplification, and there's still a hard failure path.

Why the amplification: strips are full input width (the raster API reads whole rows — his own comment), so column tiling only narrows the row span per tile. Every one of the 250–500 tiles re-reads complete rows, and neighboring tiles' row spans overlap heavily. The narrower the tiles get (i.e., the harder the transform), the more times the same row is decompressed. The design gets more wasteful exactly when the transform is hardest. The rolling window doesn't help here because within a band, tiles' spans jump around rather than advance.

How: this is precisely what the existing readcell structure already does — a 2D LRU cache of decompressed tiles under the memory cap, where re-touching a row is a cache hit. Instead of deleting it, put it behind the strip loads (or use it directly for the tiled path): banding decides the parallel compute schedule, the cache decides what's resident. Those are separate concerns; the current design fuses them, and the fusion is where the waste comes from. One caveat to design for: the old cache faults tiles in lazily, which isn't thread-safe — either pre-fault a tile's needed input serially before the parallel region (the band sizing already knows the footprint), or make it read-only during compute.

This also answers his direct question ("is a tile-cache fallback still needed now that poles work?"): yes — no longer for correctness, but as the read-amortization layer. And it removes the remaining G_fatal_error for the over-cap single row: old r.proj handled memory=2 on any CRS pair; the new code must not do worse.

  1. Overlap the output write with the next band's compute

Problem it fixes: the easy-map ceiling. Write is ~0.93 s at every thread count; at 8 threads compute is ~1.03 s, so total is ~2 s of work serialized into 2.76 s, capping the flagship benchmark at 2.72x.

How: Rast_put_row must stay sequential (compressed row offsets depend on prior rows) — but nothing says it must wait for compute. Double-buffer the band output: while the worker threads compute band N+1, one thread writes band N's rows in order. OpenMP sections or tasks do this without new dependencies. Total goes from compute + write per band to max(compute, write).

Impact: easy map at 8T: ~2.76 s → ~1.8 s, i.e. 2.72x → ~4x. It's the cheapest big win in the PR
Cost: two band buffers alive at once, so the fit search must budget 2×out_bytes + strip against ds, same logic.
4. Replace the fit search with one precomputed footprint grid
Problem it fixes: sizing is 1–2.6 s of pure serial overhead on hard maps — overhead the old codespawned the PR's most fragile code.
Why it's backwards now: the halving search re-runs a full perimeter transform walk for every cane-walks per candidate width, and the seed/probe machinery exists only to skip some of thosere-walks. That's ~200 lines resting on monotonicity arguments that a reviewer has to re-derive to trust.
How: transform once, query many times. Up front, sample the output on a coarse grid — every output row (or every few rows) × ~32 column blocks — and store each grid cell's input-row min/max. That's one O(samples) transform pass. Afterwards, any band or tile's input span is a constant-time max over stored entries: band scan (grow the band until strip + out > cap), tile widths out of a scan along the row of blocks.phase1_fits, phase2_width_fit, est_worst_tile_strip_rows, both seed paths, and TILE_PROBE all get deleted. The pole fix layers on unchanged (it's about interior extrema the sampling misses, same as today).

Impact: sizing drops to a single pass with trivial queries; simpler and faster than the seeded search he's currently tuning. It also naturally produces exact band boundaries instead of the power-of-two lattice, so bands are as tall as the cap truly allows.

  1. Benchmark row-batched transforms before settling the design

Problem it addresses: the thing being parallelized may be mostly avoidable overhead. 5.5 s of co3857 is ~53 ns/cell, and the actual math for that pair is a handful of flops. The rest is per-cell GPJ_transform overhead: a strncmp on the proj string, proj_angular_input(), PJ_COORD marshalling, and a single-point proj_trans call — per cell.

How: transform a row (or tile row) at a time with proj_trans_array-style batching — GPJ_transform_array already exists in the library. The angular/units decisions get made once per batch instead of once per cell.

Why now and not later: if batching cuts serial compute 2–4x on cheap pipelines, the compute/write/read balance the whole architecture is tuned around shifts — the write overlap (item 3) becomes more important, and the case
for aggressive threading on easy pairs weakens (a 2 s serial run doesn't need 8 threads). Cheap es design priorities, so it should happen before more parallel plumbing is added on top.

  1. Fix GPJ_transform's hidden globals in the library

Problem it fixes: the PR's central thread-safety claim is currently false. GPJ_transform assignsS_in/METERS_out on every call (lib/proj/do_proj.c:42), so the parallel loop races on them despitethe per-thread PJ clones. It happens to work here because every thread writes identical values, but it's UB, TSan will flag it, and the new GPJ_clone_transform API documents a safety guarantee the library doesn't provide.

How: make them locals of GPJ_transform (and check the other entry points that touch them). Trivial diff, but it belongs in this PR, because the entire design — and the new public API — rests on "clone the transform and the
parallel region is safe." I'd also have him add NULL checks on proj_context_create/proj_clone wha failed clone currently surfaces as a crash inside the parallel region.

Here are some bugs in the current code:

Details - All non-nearest methods silently produce nearest-neighbor output. The old dispatch interpolate = menu[method].method was deleted, but the method option still accepts bilinear, bicubic, lanczos, and the _f variants (raster/r.proj/main.c:83), and the compute loop unconditionally calls interpolate_strip() (raster/r.proj/main.c:1289), which is nearest-only. A user asking for bicubic gets nearest with no warning. Either implement the strip-based interpolators (the 2-cell margin is already reserved for their stencils) or G_fatal_error on non-nearest methods until they exist. This alone makes the PR unmergeable as-is; a test with method=bilinear would have caught it. - Functional regression for oblique/large-halo transforms. The old readcell tile cache handled any CRS pair within the memory cap by faulting tiles in from a temp file. That fallback is deleted, and the code now aborts (raster/r.proj/main.c:1167) for cases that previously worked, just slowly. The PR description acknowledges this ("tile-cache path not implemented"), but the fix should be to fall back to the existing readcell path (still in the tree, now dead code) rather than to error out. Merging a speedup that removes working functionality is not acceptable for this tool. - GPJ_transform is not actually thread-safe, even with a cloned PJ. It writes the file-scope globals METERS_in/METERS_out on every call (lib/proj/do_proj.c:42, :891). In this PR all threads write identical values so the output is correct in practice, but it is a formal data race (TSan will flag it) and — more importantly — the comments and the new API's docs claim per-thread safety that the library does not deliver. Making those two variables locals of GPJ_transform looks straightforward and would make the claim true. This needs to be part of the same PR.

@krcoder123
krcoder123 force-pushed the gsoc-rproj-banding branch from e666323 to c980ea6 Compare July 19, 2026 23:45
@krcoder123

krcoder123 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I added three major things in my most recent commits.

All the non-nearest methods now run in the parallel path. This fixes a bug where bilinear, bicubic, and lanczos silently fell back to nearest. I also found and fixed a tiny rounding mismatch in the output row coordinates, off by one bit. And I added the serial tile cache fallback, so the parallel path never fails on a case the old r.proj completed. But I found and verified that these cases that use the fallback are extremely rare. It only happens when the memory cap cannot hold one output row's input strip. So for those cases the module now warns with the exact memory value that would keep the parallel path, then finishes the run through the old readcell cache. I swept the caps to see how often this fires. It never triggered on any real projection pair down to 1 MB. Only a purposely wide input that’s wider than cap/20 columns hits it.

I also went through your review items. For number 5, which was the batching the transform calls, I measured it at about 1.39x per point. This is smaller than what the AI review had mentioned it could be. Also in general that’s a pretty small end-to-end gain for something that needs a large code refactor. So I'm not planning it right now, but I'm happy to do it if you still want it. I will work on number 1 and 3 for this week's work since that's the only thing that can help bring the total speedup up some more. On number 4, I agree the footprint grid is cleaner, but it replaces the sizing that all the results below were tested against, and with the July 24 target Huidae and I set, it seems a bit tight. But I can try if you would like to see how it looks. I’ll finish with the globals fix in a separate small PR like you suggested (with NULL checks added to the clone API), and pytest cases for bilinear, pole maps, and the fallback.

Benchmarks for all tested scenarios
"easy" EPSG:4326 to EPSG:3857, memory=50, method=nearest Serial: 7.472 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0527 0.4893 1.00x 5.5962 1.00x 0.9198 7.554 0.99x
2 0.0533 0.4418 1.11x 3.0538 1.83x 0.9326 4.958 1.51x
4 0.0542 0.3669 1.33x 1.7066 3.28x 0.9558 3.497 2.14x
8 0.0538 0.2728 1.79x 1.0786 5.19x 0.9454 2.748 2.72x
"easy" EPSG:4326 to EPSG:3857, memory=300, method=nearest Serial: 9.557 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0119 0.4846 1.00x 5.5647 1.00x 0.9118 7.511 1.27x
2 0.0119 0.4833 1.00x 3.2582 1.71x 0.9318 5.029 1.90x
4 0.0119 0.3778 1.28x 1.7343 3.21x 0.9594 3.427 2.79x
8 0.0119 0.2947 1.64x 1.1095 5.02x 0.9158 2.887 3.31x
"laea" EPSG:4326 to EPSG:3035, memory=50, method=nearest Serial: 10.696 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 1.0412 5.2100 1.00x 9.2105 1.00x 0.6820 16.949 0.63x
2 1.0524 3.0165 1.73x 5.0218 1.83x 0.7090 10.519 1.02x
4 1.0623 1.6733 3.11x 2.8466 3.24x 0.7126 7.118 1.50x
8 1.0643 1.3025 4.00x 1.8206 5.06x 0.7217 5.712 1.87x
"laea" EPSG:4326 to EPSG:3035, memory=300, method=nearest Serial: 10.729 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0276 0.4715 1.00x 9.2788 1.00x 0.6966 11.103 0.97x
2 0.0276 0.3934 1.20x 5.0818 1.83x 0.6943 6.756 1.59x
4 0.0276 0.2922 1.61x 2.8365 3.27x 0.7099 4.487 2.39x
8 0.0276 0.2838 1.66x 1.7794 5.21x 0.7046 3.371 3.18x
"pole" EPSG:4326 to EPSG:3413, memory=50, method=nearest Serial: 29.219 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.4101 1.0810 1.00x 27.8761 1.00x 0.9577 31.061 0.94x
2 0.4171 0.6385 1.69x 14.7086 1.90x 0.9820 17.518 1.67x
4 0.4192 0.4158 2.60x 7.7661 3.59x 0.9998 10.197 2.87x
8 0.4117 0.2898 3.73x 4.9252 5.66x 1.0002 7.199 4.06x
"pole" EPSG:4326 to EPSG:3413, memory=300, method=nearest Serial: 29.106 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0401 0.1407 1.00x 27.7829 1.00x 1.0151 29.547 0.99x
2 0.0403 0.1350 1.04x 14.6498 1.90x 1.0332 16.282 1.79x
4 0.0401 0.0792 1.78x 7.7658 3.58x 1.0007 9.389 3.10x
8 0.0401 0.0504 2.79x 5.0466 5.51x 1.0202 6.605 4.41x
"easy" EPSG:4326 to EPSG:3857, memory=50, method=bilinear Serial: 7.775 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0523 0.4895 1.00x 5.5179 1.00x 0.8143 7.387 1.05x
2 0.0534 0.4742 1.03x 2.8576 1.93x 0.8791 4.680 1.66x
4 0.0548 0.3439 1.42x 1.6142 3.42x 0.8330 3.248 2.39x
8 0.0539 0.2555 1.92x 1.1505 4.80x 0.8302 2.832 2.75x
"easy" EPSG:4326 to EPSG:3857, memory=300, method=bilinear Serial: 10.058 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0119 0.4763 1.00x 5.6000 1.00x 0.8950 7.320 1.37x
2 0.0119 0.4539 1.05x 2.8776 1.95x 0.8003 4.810 2.09x
4 0.0119 0.3685 1.29x 1.6216 3.45x 1.1162 3.196 3.15x
8 0.0120 0.3008 1.58x 1.1806 4.74x 1.1026 3.034 3.32x
"laea" EPSG:4326 to EPSG:3035, memory=50, method=bilinear Serial: 11.112 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 1.0514 5.6022 1.00x 9.4559 1.00x 0.7826 17.506 0.63x
2 1.0618 3.0966 1.81x 5.2767 1.79x 0.9389 11.373 0.98x
4 1.0792 1.7425 3.22x 3.0667 3.08x 0.9728 7.546 1.47x
8 1.0892 1.3957 4.01x 1.9528 4.84x 0.7186 5.976 1.86x
"laea" EPSG:4326 to EPSG:3035, memory=300, method=bilinear Serial: 11.057 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0276 0.4816 1.00x 9.4380 1.00x 0.6175 11.350 0.97x
2 0.0276 0.4522 1.07x 5.3133 1.78x 0.8851 7.122 1.55x
4 0.0275 0.3709 1.30x 3.0012 3.14x 0.9634 4.618 2.39x
8 0.0276 0.2314 2.08x 1.8948 4.98x 0.7561 3.454 3.20x
"pole" EPSG:4326 to EPSG:3413, memory=50, method=bilinear Serial: 29.498 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.4088 1.1566 1.00x 28.1110 1.00x 1.1092 31.545 0.94x
2 0.4189 0.6659 1.74x 14.8040 1.90x 1.0399 17.445 1.69x
4 0.4294 0.3861 3.00x 7.7822 3.61x 1.0466 10.316 2.86x
8 0.4258 0.3030 3.82x 5.0471 5.57x 0.8650 7.360 4.01x
"pole" EPSG:4326 to EPSG:3413, memory=300, method=bilinear Serial: 29.635 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0402 0.1523 1.00x 28.0952 1.00x 0.9760 29.363 1.01x
2 0.0400 0.1303 1.17x 14.7422 1.91x 1.1457 16.557 1.79x
4 0.0401 0.0726 2.10x 7.7557 3.62x 1.1490 9.347 3.17x
8 0.0401 0.0502 3.03x 5.1688 5.44x 1.3176 6.857 4.32x
"easy" EPSG:4326 to EPSG:3857, memory=50, method=lanczos Serial: 14.257 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0530 0.4858 1.00x 11.1076 1.00x 0.7742 13.318 1.07x
2 0.0543 0.5984 0.81x 5.7127 1.94x 1.4162 7.848 1.82x
4 0.0551 0.3708 1.31x 2.9263 3.80x 1.5161 5.580 2.56x
8 0.0554 0.5474 0.89x 2.0485 5.42x 0.8753 3.741 3.81x
"laea" EPSG:4326 to EPSG:3035, memory=50, method=lanczos Serial: 16.393 s
N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 1.0598 5.6255 1.00x 14.0126 1.00x 0.6855 22.330 0.73x
2 1.0832 3.1371 1.79x 7.2245 1.94x 0.8614 12.922 1.27x
4 1.1007 1.7511 3.21x 3.7181 3.77x 0.7001 8.201 2.00x
8 1.0971 1.3973 4.03x 2.4714 5.67x 0.8069 6.370 2.57x
Which memory caps force the serial tile cache fallback

The module falls back to the serial tile cache only when the memory cap cannot hold one output row's input strip. This table shows which inputs hit that at caps of 1, 2, 5, and 10 MB.

Input map (columns) cap=1 MB cap=2 MB cap=5 MB cap=10 MB
wide (150000) fallback fallback parallel parallel
easy (10000) parallel parallel parallel parallel
laea (10000) parallel parallel parallel parallel
pole (14400) parallel parallel parallel parallel

Only the purposely wide input falls back, and only at the two smallest caps. Every normal map stays parallel even at 1 MB.

The benchmarks show that speedup improves with the memory cap because a bigger cap means taller bands and fewer repeated input reads. At memory=300 the input read phase almost disappears on the pole map. The pole map scales best overall since its runtime is nearly all compute, which parallelizes well. The laea (hard) map at memory=50 is the weakest case because its sizing phase takes about a second of serial work before any threads start. Compute alone scales 4.8x to 5.7x at 8 threads on every map. The totals land lower than that because the sizing phase and the output write stay serial, which is why I’m trying numbers 1 and 3 from your review to hopefully fix this issue.

I also tested bit identity against serial with 33 comparisons, and all of them came out exact. That covers all seven methods on a 100M-cell pole pair at two thread counts, all seven methods again through the forced fallback, and nearest, bilinear, and lanczos on the easy and laea pairs at two thread counts.

I wanted to mention a behavioral difference from parallel r.projj and serial p.proj as well. For integer inputs below 2^24 the parallel output is exactly the same as serial, which the 33 comparisons above verify. Above 2^24 with the nearest method the two can differ by 1, and the parallel value is the correct one. This is because the old serial stores inputs as float32, which cannot hold integers that large exactly. I kept the full precision for now since it made sense to have the accurate numbers. If you would rather have byte for byte serial reproduction instead, please let me know and I will add it.

I also ran my own AI review over all the code, the benchmarks + evidence with Claude. I addressed most of them and the remaining ones I will address early this week. One last thing, the branch was rebased onto current main to pick up #7730, so history was force updated. Thank you.

@krcoder123

krcoder123 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

I created the pytest and removed the testsuite folder gunittest. The pytest is 7 method reference tests plus 4 parallel-correctness tests on a 50x50 generated data. While testing the pole edge case in the pytest I came across a band sizing bug, fixed it, and verfied the output is correct. The NULL checks are also added to the clone API, and the globals fix is up separately as #7764. Please let know if there's anything I should fix in the pytest, thanks.

@petrasovaa

Copy link
Copy Markdown
Contributor

Just a quick question, did you find a bug in the current implementation or are you referring to a bug in your prior parallel implementation? If it's a bug in non-parallel implementation, I would recommend creating a separate PR for it, so that the fix is not hidden here.

@petrasovaa

Copy link
Copy Markdown
Contributor

Also the pytest would be better as separate PR and this PR would be adding the parallel tests. This will keep this PR smaller and would catch any regressions.

@krcoder123

Copy link
Copy Markdown
Contributor Author

It was a bug in my parallel implementation's band sizing, but I was able to fix it in this PR. I'll move the method reference tests and replacing the testsuite into a separate PR, and keep the tests that check the threaded output matches the serial output here.

@krcoder123

krcoder123 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

I was able to implement numbers 1 and 3 from the review with Claude. Number 1 keeps the input rows resident across bands instead of re-reading them, and number 3 writes the previous band's output while the next band computes. Both are bitwise identical to serial across all 33 comparison cases. I also added the nprocs option to match the other parallel modules, and a thread scaling benchmark script under raster/r.proj/benchmark.

The new run of benchmarks are below. Serial was re-measured in the same session, so the speedup columns are the comparison rather than the raw seconds. At more than one thread, the output write is happening while compute is happening, so the write column only shows the final level.

The benchmark script runs for the graphs are still going, I'll post them when they finish.

But I think the module is ready for review. If anyone gets a chance to run the benchmark script on their machines I would like to see how it scales with more cores. I'll start looking at r.geomorphon while review is happening here and address any feedback or issues as they come. The method tests are in #7766 and the globals fix is in #7764. Thank you.

Benchmarks for all tested scenarios
"easy" EPSG:4326 to EPSG:3857, memory=50, method=nearest

Serial: 9.991s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0540 0.4618 1.00x 5.6892 1.00x 0.9215 7.517 1.33x
2 0.0803 0.4579 1.01x 3.4562 1.65x 0.0408 4.643 2.15x
4 0.0808 0.3636 1.27x 1.8670 3.05x 0.0413 2.834 3.53x
8 0.0798 0.2823 1.64x 1.3093 4.35x 0.0380 2.109 4.74x
"easy" EPSG:4326 to EPSG:3857, memory=300, method=nearest

Serial: 9.832s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0119 0.5155 1.00x 5.7428 1.00x 1.0814 7.672 1.28x
2 0.0149 0.4499 1.15x 3.5172 1.63x 0.1998 4.609 2.13x
4 0.0151 0.3559 1.45x 1.8760 3.06x 0.2079 2.910 3.38x
8 0.0148 0.2597 1.98x 1.3517 4.25x 0.2104 2.241 4.39x
"easy" EPSG:4326 to EPSG:3857, memory=50, method=bilinear

Serial: 10.633s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0528 0.4611 1.00x 5.4913 1.00x 1.3211 7.650 1.39x
2 0.0799 0.4659 0.99x 3.2426 1.69x 0.0321 4.405 2.41x
4 0.0797 0.3941 1.17x 1.8952 2.90x 0.0413 2.766 3.84x
8 0.0809 0.2677 1.72x 1.3176 4.17x 0.0408 2.163 4.92x
"easy" EPSG:4326 to EPSG:3857, memory=300, method=bilinear

Serial: 9.962s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0120 0.4848 1.00x 5.5585 1.00x 0.8935 7.537 1.32x
2 0.0144 0.4676 1.04x 3.2238 1.72x 0.1811 4.439 2.24x
4 0.0150 0.3320 1.46x 1.7155 3.24x 0.2075 2.729 3.65x
8 0.0145 0.2831 1.71x 1.3129 4.23x 0.1818 2.307 4.32x
"laea" EPSG:4326 to EPSG:3035, memory=50, method=nearest

Serial: 13.466s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 1.0428 1.4329 1.00x 9.4251 1.00x 0.7306 13.571 0.99x
2 1.2373 4.7199 0.30x 5.5589 1.70x 0.0158 12.352 1.09x
4 1.2482 2.5418 0.56x 3.1626 2.98x 0.0178 7.892 1.71x
8 1.2522 2.0336 0.70x 2.0757 4.54x 0.0161 6.336 2.13x
"laea" EPSG:4326 to EPSG:3035, memory=300, method=nearest

Serial: 10.792s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0275 0.3408 1.00x 9.4141 1.00x 0.6693 11.097 0.97x
2 0.0324 0.4120 0.83x 5.3808 1.75x 0.2261 6.652 1.62x
4 0.0337 0.2943 1.16x 2.9690 3.17x 0.2277 4.212 2.56x
8 0.0334 0.2290 1.49x 1.8494 5.09x 0.2369 3.112 3.47x
"laea" EPSG:4326 to EPSG:3035, memory=50, method=bilinear

Serial: 13.648s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 1.0403 1.4858 1.00x 9.2705 1.00x 0.6952 13.308 1.03x
2 1.2320 4.8237 0.31x 5.6061 1.65x 0.0133 12.621 1.08x
4 1.2753 2.8501 0.52x 3.3479 2.77x 0.0215 8.163 1.67x
8 1.2563 2.0959 0.71x 2.2530 4.11x 0.0112 6.527 2.09x
"laea" EPSG:4326 to EPSG:3035, memory=300, method=bilinear

Serial: 11.146s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0280 0.3633 1.00x 9.4727 1.00x 0.6186 10.934 1.02x
2 0.0337 0.4258 0.85x 5.4552 1.74x 0.1875 6.716 1.66x
4 0.0354 0.3556 1.02x 3.1158 3.04x 0.1914 4.326 2.58x
8 0.0335 0.2333 1.56x 2.0227 4.68x 0.2513 3.117 3.58x
"pole" EPSG:4326 to EPSG:3413, memory=50, method=nearest

Serial: 29.684s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.4070 0.5392 1.00x 27.8485 1.00x 0.9542 30.165 0.98x
2 0.7370 1.1062 0.49x 15.3107 1.82x 0.0426 17.846 1.66x
4 0.7562 0.6658 0.81x 8.0379 3.46x 0.0411 10.212 2.91x
8 0.7550 0.4539 1.19x 5.0610 5.50x 0.0407 7.102 4.18x
"pole" EPSG:4326 to EPSG:3413, memory=300, method=nearest

Serial: 29.276s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0400 0.0796 1.00x 27.8172 1.00x 1.1601 29.479 0.99x
2 0.0822 0.1422 0.56x 14.8932 1.87x 0.3277 16.270 1.80x
4 0.0852 0.1061 0.75x 7.9971 3.48x 0.3373 9.011 3.25x
8 0.0840 0.0624 1.28x 5.0629 5.49x 0.3124 6.030 4.86x
"pole" EPSG:4326 to EPSG:3413, memory=50, method=bilinear

Serial: 29.659s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.4078 0.5553 1.00x 27.6120 1.00x 0.7782 30.032 0.99x
2 0.7394 1.1008 0.50x 15.3968 1.79x 0.0360 17.826 1.66x
4 0.7546 0.6102 0.91x 8.0210 3.44x 0.0301 10.198 2.91x
8 0.7523 0.4540 1.22x 5.3407 5.17x 0.0305 7.313 4.06x
"pole" EPSG:4326 to EPSG:3413, memory=300, method=bilinear

Serial: 29.169s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0401 0.0791 1.00x 27.6727 1.00x 0.8271 29.091 1.00x
2 0.0822 0.1391 0.57x 14.8689 1.86x 0.2533 15.807 1.85x
4 0.0849 0.1062 0.74x 7.9596 3.48x 0.2451 8.903 3.28x
8 0.0842 0.0552 1.43x 5.2520 5.27x 0.2455 6.235 4.68x
"easy" EPSG:4326 to EPSG:3857, memory=50, method=lanczos

Serial: 16.118s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 0.0526 0.4442 1.00x 11.1363 1.00x 0.8250 12.785 1.26x
2 0.0807 0.4756 0.93x 6.1194 1.82x 0.0385 7.141 2.26x
4 0.0793 0.3869 1.15x 3.1616 3.52x 0.0455 4.167 3.87x
8 0.0811 0.2763 1.61x 2.1873 5.09x 0.0357 3.105 5.19x
"laea" EPSG:4326 to EPSG:3035, memory=50, method=lanczos

Serial: 18.258s (measured same session)

N Sizing (s) Input read (s) IR spd Compute (s) C spd Output write (s) Total (s) Speedup
1 1.0431 1.4659 1.00x 13.7177 1.00x 0.6312 17.643 1.03x
2 1.2590 4.8664 0.30x 7.5658 1.81x 0.0103 14.445 1.26x
4 1.2587 2.6070 0.56x 3.9440 3.48x 0.0145 8.783 2.08x
8 1.2725 2.2744 0.64x 3.1160 4.40x 0.0171 7.240 2.52x
Fallback sweep: memory-cap bail boundary (out_mult=2)
Map cap (MB) footprint rows fallback
wide 1 3 YES
wide 2 3 YES
wide 5 NA no
wide 10 NA no
easy 1 NA no
easy 2 NA no
easy 5 NA no
easy 10 NA no
laea 1 NA no
laea 2 NA no
laea 5 NA no
laea 10 NA no
pole 1 NA no
pole 2 NA no
pole 5 NA no
pole 10 NA no

@krcoder123

Copy link
Copy Markdown
Contributor Author

These are my graphs. Scaling seems to be strongest near the default memory setting. I also noticed with a higher memory value the bands get taller and fewer, so there is less overlap between writing one band and computing the next. This makes the final write at the end a larger serial share of the run, which is exactly how I expected it to behave given the write has to stay sequential.

image image image image image image

@krcoder123
krcoder123 marked this pull request as ready for review July 24, 2026 04:24
Kaushik Raja and others added 23 commits July 26, 2026 13:45
Replace the whole-map RAM buffer (Path A) with a two-level band loop
modeled on r.neighbors, adapted for r.proj's CRS-dependent input access:
each output band's input footprint is found by back-projecting the band's
edges (dense edge walk), so the loaded input strip is sized per band rather
than by a fixed neighborhood stencil. Band height adapts to the memory
option; if a single output row's footprint exceeds the cap (oblique or
large-halo transforms) the module bails, since that case needs the tile
cache path, which is not implemented here.

Per-thread PROJ contexts (one PJ clone per thread) are retained. Input
strips are loaded serially per band because a single fd read path is not
thread-safe; each band's output is written in order.

Bit-exact against the serial output at 1/2/4/8 threads, for both
column-varying and row-varying inputs, with multiple bands exercised. On
the test case (105.3M output cells, EPSG:4326 to EPSG:3857, nearest,
memory=50) peak RSS was 130 MB versus 763 MB for the whole-map buffer.

Developed with assistance from Claude (Anthropic).
The banding timers call omp_get_wtime(), which is undefined when GRASS is
built without OpenMP and breaks the link in the minimum-config build. Wrap
omp.h and omp_get_wtime() behind _OPENMP via a small rproj_wtime() helper
that returns 0.0 without OpenMP. Also demote the PHASE_TIMERS line from
G_message to G_debug, since it is benchmark scaffolding, not user output.
PROJ transformation objects are not safe for concurrent use, so a parallel
module needs a private clone per thread. Add GPJ_clone_transform() and
GPJ_free_transform_clone(), which bundle a cloned transform with its private
PROJ context in struct gpj_transform_clone so ownership is a single unit.

r.proj's parallel banding created the per-thread context with
proj_context_create(), proj_clone(), and proj_destroy() directly; switch it
to these helpers so the PROJ calls live in lib/proj and the module makes
none.
Each band's input strip is read in parallel: read_nprocs fresh per-thread
fds (Rast_open_old), a static block split of the strip rows across threads,
each thread reading its disjoint rows through its own fd into its own strip
slice. Rast_disable_omp_on_mask gates the parallelism (serial when a mask is
present or without OpenMP); fdi remains the serial-fallback path. Env-switch
choreography, compute region, write loop, band sizing, and PJ context cloning
are unchanged. Experimental, not for merge.
The memory-bounded banding path halves the band height until a
full-width input strip fits the memory cap. On oblique projections a
single output row can back-project to an input footprint larger than
the cap at any height, and that path bailed out.

Add column tiling as a second search dimension. Phase 1 is identical to
the current banding: halve the band height while the input strip spans
the full input width, and use that result whenever a full-width band
fits. Phase 2 runs only when a single full-width row still exceeds the
cap. It keeps the band as tall as its output buffer allows and halves
the tile width instead, so each column tile back-projects to a smaller
input row span and the per-band parallel region stays populated rather
than collapsing to a single row.

The width search runs in two tiers to stay cheap. The upper tier
estimates the worst tile strip by probing a bounded, evenly spaced
subset of tiles, which is a lower bound on the true worst, and narrows
to a candidate width. The lower tier validates that width with the exact
per-tile edge walk and narrows further if the estimate was optimistic,
so the accepted width is always exact-sized against the cap.

Input strips stay full width because the raster API reads whole rows, so
a tile strip is its input row span times the full input width, and
tiling shrinks the row span rather than the width. Each tile loads one
strip whose row span comes from the exact edge walk, one tile at a time,
bounding peak memory to the worst tile rather than the whole band. Every
output cell is computed once and written in row order, so the result is
bit-exact with the serial output.

Retain the existing fatal error only for the degenerate tile whose
footprint cannot fit the cap at any width, at minimum band height; that
footprint needs the tile-cache path, which is not implemented.
The tile sizing search finds, for each output band, the tallest band
height and its column tiling whose input strip fits the memory cap.
Neighboring bands almost always end up with the same size, since the
projection changes gradually from one band to the next. The search
now tries the previous band's accepted height and width first instead
of restarting the descending scan from the top every time.

The previous band's size is checked with the same exact edge walk
acceptance test the full search uses, and the next taller height is
checked to make sure it does not fit. Together these two checks
confirm the reused size is the tallest fitting answer, the same
result the full scan would have returned. If either check fails, the
code falls back to the full descending search, so the worst case
costs the same as before.

Because acceptance is decided by the same test in both paths, the
resulting bands and tiles are identical to before and the output is
bit for bit unchanged. In the common case a band is sized in two edge
walks instead of a full descending scan.
The memory-bounded band sizing walks each output tile's perimeter to
bound the range of input rows the tile needs, then loads that strip. A
tile whose interior contains a geographic pole has its northmost or
southmost latitude at the pole, in the tile interior, where the
perimeter walk never samples it. The strip was therefore sized too
small, and projecting a pole-containing map aborted with a "Band strip
under-sized" error at every memory setting, though the projection
itself was well defined.

This computes, once per map, each geographic pole that lies within the
input's latitude coverage: its coordinate in the output projection and
its input row. When an output tile's rectangle contains a pole, that
pole's input row is folded into the tile's row span, so the height and
width search sees the true footprint and shrinks pole tiles until they
fit the memory cap. The loaded strip then covers every row the fill
reads.

Only lat/lon input is handled, where a pole is at latitude 90 or -90.
If the pole's coordinate transform fails or returns a non-finite value
the pole is skipped and the existing under-size guard stays as the
backstop. A map with no pole in the output frame is unaffected: the row
spans, the band and tile partition, and the output are byte for byte
unchanged.
The banded compute path dispatched a nearest-only strip reader for every
resampling method, so bilinear, bicubic, lanczos and their fallback
variants silently produced nearest-neighbor output.

Add interp_strip.c with strip counterparts of the cache kernels
(strip_bilinear, strip_cubic, strip_lanczos, and the three _f fallbacks).
They read the in-RAM full-width FCELL band strip the banded path already
loads, using the same base index, bounds, weights, and null fallback as the
readcell-cache kernels in bilinear.c, cubic.c, and lanczos.c. A
strip_kernels[] table, ordered like menu[], resolves each method to its
strip counterpart once after option parsing; nearest keeps the existing
interpolate_strip reader in slot 0.

Output is bitwise identical to serial r.proj for all seven methods across
the test datasets.
Serial r.proj computes each output row's northing by subtracting
ns_res row by row. The banded path computed it directly as
north - ns_res/2 - row * ns_res, which can differ by one ulp when
ns_res is not exactly representable. Nearest is unaffected, but for
the other methods the shifted interpolation weights changed a few
cells (29 of 76M on the EPSG:3035 test).

Precompute the row northings once with the serial recurrence and use
that array in both the sizing walk and the compute loop.
The banded path aborted when the memory cap could not hold even one
output row's input strip. In practice that needs an input wider than
about cap/20 columns; poles and oblique projections do not trigger it.

Warn with the minimum memory that keeps the parallel path, then finish
the run through the old serial readcell cache. Output is bitwise
identical to serial r.proj. Failed transforms set the cell NULL like
the banded path does. R_PROJ_FORCE_TILECACHE forces the fallback for
testing.
GPJ_clone_transform did not check the results of proj_context_create()
and proj_clone(). Either can return NULL on failure, and the NULL would
otherwise surface later as a crash deep inside PROJ when the cloned
transform is first used.

Both are now checked and fail with G_fatal_error naming the call. r.proj
calls this once per worker thread, so a failure terminates the process
from inside the parallel region; that is intended, since a clone failure
leaves the thread with no usable transform.
…input

A polar-stereographic output frame centered on a pole, reprojecting an
input truncated below that pole (e.g. input reaching 89 degrees, not 90),
aborted with "Band strip under-sized" (or, before that guard existed,
silently read garbage). The banded strip sizing sampled only the output
tile's perimeter, so the frame-center-proximal interior cell that reaches
the input's northernmost edge row was never seen, and the strip loaded too
few input rows.

The pole footprint fold already handled a pole lying inside the input map;
it now also folds in the input's edge row (0 or rows-1) when a pole outside
the input's latitude coverage still projects into the frame. Gated by the
existing point-in-rect test, so bands that do not image a pole compute
byte-identical spans; verified unchanged on non-pole frames.
Adds pytest tests that verify r.proj's banded parallel output matches its
serial output on generated CI-sized data. r.proj has no nprocs option, so
each run sets OMP_NUM_THREADS on a per-call environment copy to select the
serial (1) or parallel (N) path without mutating shared state.

Four tests: bilinear identity (with a nearest-vs-bilinear dispatch-liveness
guard so a silent fallback to nearest cannot pass the check vacuously),
nearest identity under a constrained memory cap that forces band sizing,
nearest identity into a pole-centered frame, and a forced tile-cache
fallback (R_PROJ_FORCE_TILECACHE) compared against the banded path to cover
both code paths. Inputs are integer CELL below 2^24 so the FCELL readcell
cache round-trips losslessly.
Ports the 7 interpolation-method tests from testsuite/test_rproj.py to a
parametrized pytest (r_proj_methods_test.py) over generated CI-sized data,
with references captured from the serial binary. Removes the gunittest file.

Drops its 4 output-format tests (test_list_output_plain/json,
test_print_output_plain/json), which asserted NC-SPM-specific -l/-p output;
r.proj's -l and -p flags are left without test coverage as a result.
Overlapping input rows are kept between single-tile bands and only new
rows are read, instead of re-reading each band's full span. Cuts the
input read phase about 70 percent on the wide LAEA benchmark; output is
bitwise identical to serial.
The band output buffer is now double-buffered when running with more
than one thread: one thread writes the previous band's rows in order
while the rest compute the current band. Single-thread runs keep the
sequential write. Hides most of the output write time at higher thread
counts; output is bitwise identical to serial.
The method reference tests and the gunittest-to-pytest testsuite
replacement move to a dedicated PR (branch fix-rproj-tests) so this PR
stays focused on the OpenMP parallelization and that PR catches serial
regressions on its own.

This drops raster/r.proj/tests/r_proj_methods_test.py and restores
raster/r.proj/testsuite/test_rproj.py to its main state; the split PR
carries the deletion, so this restore is temporary and goes away once
that PR merges. The parallel-correctness tests (r_proj_parallel_test.py)
and their conftest.py stay here; conftest.py gains a note that it is
duplicated on the split branch.
Add the standard G_OPT_M_NPROCS option so the compute thread count can be
set with nprocs= instead of only OMP_NUM_THREADS. A value above zero
overrides OMP_NUM_THREADS and zero keeps the OpenMP default. The option is
read once through compute_nprocs() before the band fit search, so it drives
the compute region, the per-thread read fds, and the output double-buffer
together. The parallel-correctness tests now pass nprocs= instead of setting
OMP_NUM_THREADS.

Also shorten the main.c comments to flowing prose, dropping restated design
narration and internal shorthand while keeping the load-bearing rationale.
Add a benchmark that sweeps the nprocs= thread count from 1 to 8 at two
memory caps and plots the time, speedup, and efficiency metrics, following
the r.param.scale benchmark template with grass.benchmark. It builds a source
project and reprojects a generated raster from EPSG:4326 into EPSG:3857 in a
temporary database, so it is self-contained.
@krcoder123
krcoder123 force-pushed the gsoc-rproj-banding branch from a41dd0f to 003e7ab Compare July 27, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C Related code is in C CMake libraries module Python Related code is in Python raster Related to raster data processing tests Related to Test Suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants