From 0d3a62c537147bc04b0a7f07c63a1cc573dd0d73 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Sun, 15 Mar 2026 23:21:16 -0500 Subject: [PATCH 01/32] POC: parallelize r.proj via RAM-resident buffer, 2.5x speedup on 8-core Apple M-series --- raster/r.proj/main.c | 119 +++++++++++++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 39 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 59702cdfc92..2d57c1b52d0 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -66,6 +66,10 @@ #include #include "r.proj.h" +#define PACKAGE "grassmods" + +#include + /* modify this table to add new methods */ struct menu menu[] = { {p_nearest, "nearest", "nearest neighbor"}, @@ -80,6 +84,28 @@ struct menu menu[] = { static char *make_ipol_list(void); static char *make_ipol_desc(void); + +/* Custom Interpolation for RAM Bypass - Lock-Free */ +void interpolate_ram(void *full_map, void *obufptr, int cell_type, + double col_idx, double row_idx, struct Cell_head *incellhd) +{ + int c = (int)floor(col_idx); + int r = (int)floor(row_idx); + int cell_size = Rast_cell_size(cell_type); + + /* Boundary check */ + if (r < 0 || r >= incellhd->rows || c < 0 || c >= incellhd->cols) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + /* Direct memory access - Thread Safe for Reads */ + unsigned char *src = (unsigned char *)full_map + + (((size_t)r * incellhd->cols + c) * cell_size); + memcpy(obufptr, src, cell_size); +} + + int main(int argc, char **argv) { char *mapname, /* ptr to name of output layer */ @@ -671,7 +697,6 @@ int main(int argc, char **argv) cell_type = Rast_get_map_type(fdi); ibuffer = readcell(fdi, memory->answer); Rast_close(fdi); - /* And switch back to original location */ G_switch_env(); Rast_set_output_window(&outcellhd); @@ -702,58 +727,73 @@ int main(int argc, char **argv) xcoord2 = outcellhd.west + (outcellhd.ew_res / 2); ycoord2 = outcellhd.north - (outcellhd.ns_res / 2); - G_important_message(_("Projecting...")); - for (row = 0; row < outcellhd.rows; row++) { - /* obufptr = obuffer */; - G_percent(row, outcellhd.rows - 1, 2); - -#if 0 - /* parallelization does not always work, - * segfaults in the interpolation functions - * can happen */ -#pragma omp parallel for schedule(static) -#endif + /* --- RAM BYPASS START --- */ + /* 1. Allocate a flat buffer for the entire input map in RAM */ + size_t total_cells = (size_t)incellhd.rows * incellhd.cols; - for (col = 0; col < outcellhd.cols; col++) { - void *obufptr = - (void *)((const unsigned char *)obuffer + col * cell_size); + /* Simple memory safety check */ + double memory_mb = (double)(total_cells * cell_size) / (1024.0 * 1024.0); + G_debug(1, "Bypass buffer requires %.2f MB of RAM", memory_mb); - double xcoord1 = xcoord2 + (col)*outcellhd.ew_res; - double ycoord1 = ycoord2; + if (memory_mb > 4000) { + G_warning(_("Input map requires %.2f MB of RAM for parallel processing. " + "If this causes a crash, reduce the region size with g.region."), + memory_mb); + } + /* Proceed with allocation */ + void *full_map_array = G_malloc(total_cells * cell_size); + + if (!full_map_array) + G_fatal_error("Insufficient RAM for bypass. Try a smaller region."); + + G_important_message(_("Loading map into RAM buffer for parallel bypass...")); + + /* 2. Sequential load (Single-threaded, library-safe) */ + /* ibuffer is already loaded by readcell, but we move it to a flat array + so we can access it lock-free without the readcell tile cache logic */ + for (int r = 0; r < incellhd.rows; r++) { + void *row_ptr = (void *)((unsigned char *)full_map_array + ((size_t)r * incellhd.cols * cell_size)); + /* We use the already-loaded cache here, but single-threaded */ + interpolate(ibuffer, row_ptr, cell_type, 0.0, (double)r, &incellhd); + G_percent(r, incellhd.rows - 1, 5); + } - /* project coordinates in output matrix to */ - /* coordinates in input matrix */ - if (GPJ_transform(&oproj, &iproj, &tproj, PJ_FWD, &xcoord1, - &ycoord1, NULL) < 0) { - G_fatal_error(_("Error in %s"), "GPJ_transform()"); - Rast_set_null_value(obufptr, 1, cell_type); - } - else { - /* convert to row/column indices of input matrix */ + G_important_message(_("Projecting (Lock-Free Parallel)...")); + + #pragma omp parallel for private(row, col) schedule(dynamic) + for (row = 0; row < outcellhd.rows; row++) { + void *local_obuffer = Rast_allocate_output_buf(cell_type); + double local_y = outcellhd.north - (outcellhd.ns_res / 2) - (row * outcellhd.ns_res); + double local_x_start = outcellhd.west + (outcellhd.ew_res / 2); - /* column index in input matrix */ - double col_idx = (xcoord1 - incellhd.west) / incellhd.ew_res; + for (col = 0; col < outcellhd.cols; col++) { + void *obufptr = (void *)((unsigned char *)local_obuffer + (size_t)col * cell_size); + double x1 = local_x_start + (col * outcellhd.ew_res); + double y1 = local_y; - /* row index in input matrix */ - double row_idx = (incellhd.north - ycoord1) / incellhd.ns_res; + if (GPJ_transform(&oproj, &iproj, &tproj, PJ_FWD, &x1, &y1, NULL) < 0) { + Rast_set_null_value(obufptr, 1, cell_type); + } else { + double c_idx = (x1 - incellhd.west) / incellhd.ew_res; + double r_idx = (incellhd.north - y1) / incellhd.ns_res; - /* and resample data point */ - interpolate(ibuffer, obufptr, cell_type, col_idx, row_idx, - &incellhd); + /* CALL OUR LOCK-FREE RAM INTERPOLATOR */ + interpolate_ram(full_map_array, obufptr, cell_type, c_idx, r_idx, &incellhd); } - - /* obufptr = G_incr_void_ptr(obufptr, cell_size); */ } - Rast_put_row(fdo, obuffer, cell_type); - - xcoord2 = outcellhd.west + (outcellhd.ew_res / 2); - ycoord2 -= outcellhd.ns_res; + #pragma omp critical + { + Rast_put_row(fdo, local_obuffer, cell_type); + } + G_free(local_obuffer); } + /*RAM BYPASS END*/ Rast_close(fdo); release_cache(ibuffer); + G_free(full_map_array); /* Clean up our bypass buffer */ if (have_colors > 0) { Rast_write_colors(mapname, G_mapset(), &colr); @@ -811,3 +851,4 @@ char *make_ipol_desc(void) return buf; } + From 9718ab58a7e7c2678e32d3f90e954fc419410449 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Mon, 16 Mar 2026 17:27:58 -0500 Subject: [PATCH 02/32] r.proj: use memory option to limit RAM buffer per community feedback --- raster/r.proj/main.c | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 2d57c1b52d0..40a3f8be2c2 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -736,10 +736,13 @@ int main(int argc, char **argv) double memory_mb = (double)(total_cells * cell_size) / (1024.0 * 1024.0); G_debug(1, "Bypass buffer requires %.2f MB of RAM", memory_mb); - if (memory_mb > 4000) { - G_warning(_("Input map requires %.2f MB of RAM for parallel processing. " - "If this causes a crash, reduce the region size with g.region."), - memory_mb); + double user_limit_mb = atof(memory->answer); + if (memory_mb > user_limit_mb) { + G_warning(_("Input map requires %.2f MB of RAM for parallel processing, " + "which exceeds the current memory limit (%.0f MB)."), + memory_mb, user_limit_mb); + G_important_message(_("The process may crash if system RAM is insufficient. " + "Increase the 'memory' option or use g.region to reduce the area.")); } /* Proceed with allocation */ void *full_map_array = G_malloc(total_cells * cell_size); From b69274ce42e26c823e4728d3ee042b031336bcb5 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 1 Jul 2026 21:26:58 -0700 Subject: [PATCH 03/32] r.proj: replace full-map RAM buffer with memory-bounded banding 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). --- raster/r.proj/main.c | 331 ++++++++++++++++++++++++++++++------------- 1 file changed, 233 insertions(+), 98 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 40a3f8be2c2..7afc15e60e8 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -84,27 +84,105 @@ struct menu menu[] = { static char *make_ipol_list(void); static char *make_ipol_desc(void); - -/* Custom Interpolation for RAM Bypass - Lock-Free */ -void interpolate_ram(void *full_map, void *obufptr, int cell_type, - double col_idx, double row_idx, struct Cell_head *incellhd) +/* Nearest read from an in-RAM input STRIP holding input rows [imin, imax]. + * col_idx/row_idx are full-map input indices; the strip is addressed relative + * to imin. A sample inside the full input map but outside the loaded strip + * means the band footprint was under-sized: this is the stop-on-divergence + * trip (must never fire if band_input_row_span is correct). Lock-free: reads + * only, disjoint output slots per thread. */ +static void interpolate_strip(void *strip, void *obufptr, int cell_type, + double col_idx, double row_idx, + struct Cell_head *incellhd, int imin, int imax) { int c = (int)floor(col_idx); int r = (int)floor(row_idx); int cell_size = Rast_cell_size(cell_type); - /* Boundary check */ + /* Outside the full input map: legitimate NULL (same as p_nearest). */ if (r < 0 || r >= incellhd->rows || c < 0 || c >= incellhd->cols) { Rast_set_null_value(obufptr, 1, cell_type); return; } - /* Direct memory access - Thread Safe for Reads */ - unsigned char *src = (unsigned char *)full_map + - (((size_t)r * incellhd->cols + c) * cell_size); + /* Inside the map but outside the loaded strip: the span check under-sized + * the strip. This is a correctness failure, not a NULL. */ + if (r < imin || r > imax) + G_fatal_error(_("Band strip under-sized: input row %d outside loaded " + "range [%d, %d] at column %d"), + r, imin, imax, c); + + unsigned char *src = + (unsigned char *)strip + + (((size_t)(r - imin) * incellhd->cols + c) * cell_size); memcpy(obufptr, src, cell_size); } +/* Dense edge-walk of an output band's rectangle [obr0, obr1) projected into + * input space; returns the min/max INPUT ROW touched, plus a 2-cell margin, + * clamped to the input map. Samples the band's top and bottom rows across all + * columns and its left and right columns across all band rows (bordwalk-style), + * so a curved transform's interior-edge extremum is caught -- corner-only + * sampling can under-size the strip. Called serially, before the parallel + * region, so the shared tproj is safe here. Returns imax < imin for a band + * that projects entirely outside the input. */ +static void band_input_row_span(const struct Cell_head *ohd, + const struct Cell_head *ihd, + const struct pj_info *oproj, + const struct pj_info *iproj, + const struct pj_info *tproj, int obr0, int obr1, + int *imin, int *imax) +{ + double rmin = 1e300, rmax = -1e300; + int e, r, c; + + /* top edge (row obr0) and bottom edge (row obr1-1), all columns */ + for (e = 0; e < 2; e++) { + int orow = (e == 0) ? obr0 : (obr1 - 1); + double y = ohd->north - (orow + 0.5) * ohd->ns_res; + for (c = 0; c < ohd->cols; c++) { + double x = ohd->west + (c + 0.5) * ohd->ew_res; + double xx = x, yy = y; + if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &xx, &yy, NULL) < 0) + continue; + double ri = (ihd->north - yy) / ihd->ns_res; + if (ri < rmin) + rmin = ri; + if (ri > rmax) + rmax = ri; + } + } + /* left edge (col 0) and right edge (col cols-1), all band rows */ + for (e = 0; e < 2; e++) { + int ocol = (e == 0) ? 0 : (ohd->cols - 1); + double x = ohd->west + (ocol + 0.5) * ohd->ew_res; + for (r = obr0; r < obr1; r++) { + double y = ohd->north - (r + 0.5) * ohd->ns_res; + double xx = x, yy = y; + if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &xx, &yy, NULL) < 0) + continue; + double ri = (ihd->north - yy) / ihd->ns_res; + if (ri < rmin) + rmin = ri; + if (ri > rmax) + rmax = ri; + } + } + + if (rmax < rmin) { /* band projects entirely outside the input */ + *imin = 0; + *imax = -1; + return; + } + + int lo = (int)floor(rmin) - 2; /* 2-cell margin for interp stencils */ + int hi = (int)floor(rmax) + 2; + if (lo < 0) + lo = 0; + if (hi > ihd->rows - 1) + hi = ihd->rows - 1; + *imin = lo; + *imax = hi; +} int main(int argc, char **argv) { @@ -124,14 +202,7 @@ int main(int argc, char **argv) overwrite, /* Overwrite */ curr_proj; /* output projection (see gis.h) */ - void *obuffer; /* buffer that holds one output row */ - - struct cache *ibuffer; /* buffer that holds the input map */ - func interpolate; /* interpolation routine */ - - double xcoord2, /* temporary x coordinates */ - ycoord2, /* temporary y coordinates */ - onorth, osouth, /* save original border coords */ + double onorth, osouth, /* save original border coords */ oeast, owest, inorth, isouth, ieast, iwest; char north_str[30], south_str[30], east_str[30], west_str[30]; @@ -302,7 +373,6 @@ int main(int argc, char **argv) if (!ipolname) G_fatal_error(_("<%s=%s> unknown %s"), interpol->key, interpol->answer, interpol->key); - interpolate = menu[method].method; mapname = outmap->answer ? outmap->answer : inmap->answer; if (mapname && !list->answer && !overwrite && !print_bounds->answer && @@ -690,18 +760,23 @@ int main(int argc, char **argv) G_message(_("NS-res: %f"), outcellhd.ns_res); G_message(" "); - /* open and read the relevant parts of the input map and close it */ + /* Open the input map (input location env). Banding loads only per-band + * input strips, not the whole map, so fdi stays open across the band loop. + */ G_switch_env(); Rast_set_input_window(&incellhd); fdi = Rast_open_old(inmap->answer, setname); cell_type = Rast_get_map_type(fdi); - ibuffer = readcell(fdi, memory->answer); - Rast_close(fdi); - /* And switch back to original location */ + if (strcmp(interpol->answer, "nearest") != 0) + cell_type = FCELL_TYPE; + cell_size = Rast_cell_size(cell_type); + + /* Back to the output location: set output window, init transform, open + * output map. Both fds now stay open; rd_window/wr_window are set and + * survive env switches, so reads/writes use the right windows throughout. + */ G_switch_env(); Rast_set_output_window(&outcellhd); - - /* reproject from output to input */ G_unset_window(); G_set_window(&outcellhd); tproj.def = NULL; @@ -712,91 +787,152 @@ int main(int argc, char **argv) if (GPJ_init_transform(&oproj, &iproj, &tproj) < 0) G_fatal_error(_("Unable to initialize coordinate transformation")); - if (strcmp(interpol->answer, "nearest") == 0) { + if (strcmp(interpol->answer, "nearest") == 0) fdo = Rast_open_new(mapname, cell_type); - obuffer = (CELL *)Rast_allocate_output_buf(cell_type); - } - else { + else fdo = Rast_open_fp_new(mapname); - cell_type = FCELL_TYPE; - obuffer = (FCELL *)Rast_allocate_output_buf(cell_type); - } - - cell_size = Rast_cell_size(cell_type); - xcoord2 = outcellhd.west + (outcellhd.ew_res / 2); - ycoord2 = outcellhd.north - (outcellhd.ns_res / 2); - - - /* --- RAM BYPASS START --- */ - /* 1. Allocate a flat buffer for the entire input map in RAM */ - size_t total_cells = (size_t)incellhd.rows * incellhd.cols; - - /* Simple memory safety check */ - double memory_mb = (double)(total_cells * cell_size) / (1024.0 * 1024.0); - G_debug(1, "Bypass buffer requires %.2f MB of RAM", memory_mb); + /* Banding (r.neighbors two-level structure): outer serial band loop -> + * serial strip load -> parallel compute into a per-band buffer -> serial + * in-order per-band write -> next band. Bounds peak memory by the cap + * instead of the whole input map (Path A). */ + double cap_mb = atof(memory->answer); + size_t cap_bytes = (size_t)(cap_mb * 1024.0 * 1024.0); + double t_size = 0.0, t_fill = 0.0, t_compute = 0.0, t_write = 0.0; + int n_bands = 0; + + G_important_message(_("Projecting (banded, per-thread PROJ context)...")); + + int obr0 = 0; + while (obr0 < outcellhd.rows) { + /* Band height: start from all remaining rows, halve until the input + * strip plus the band's output buffer fit the cap. band_input_row_span + * is RE-RUN for every candidate height -- the previous band's span is + * never reused. */ + double ts = omp_get_wtime(); + int band_orows = outcellhd.rows - obr0; + int imin = 0, imax = -1; + for (;;) { + band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + obr0, obr0 + band_orows, &imin, &imax); + int strip_rows = imax - imin + 1; + size_t strip_bytes = + strip_rows > 0 ? (size_t)strip_rows * incellhd.cols * cell_size + : 0; + size_t out_bytes = (size_t)band_orows * outcellhd.cols * cell_size; + if (strip_bytes + out_bytes <= cap_bytes) + break; + if (band_orows == 1) + G_fatal_error( + _("A single output row needs %.1f MB (input footprint %d " + "rows), exceeding the memory cap (%.1f MB). This " + "large-halo/oblique case needs the tile-cache path, " + "which is not implemented."), + (double)(strip_bytes + out_bytes) / (1024.0 * 1024.0), + strip_rows, cap_mb); + band_orows = (band_orows + 1) / 2; /* halve (round up), re-sample */ + } + t_size += omp_get_wtime() - ts; + + int obr1 = obr0 + band_orows; + int strip_rows = imax - imin + 1; + n_bands++; + + /* Serial strip load (single fd -> get_row not thread-safe). Reads in + * the INPUT env (matching the serial code's invariant), then back to + * OUTPUT for compute+write. EMPTY BAND: strip_rows <= 0 means the band + * projects entirely outside the input -> no malloc, no read; its cells + * become NULL via interpolate_strip's out-of-map path. */ + void *strip = NULL; + if (strip_rows > 0) { + strip = G_malloc((size_t)strip_rows * incellhd.cols * cell_size); + double t0 = omp_get_wtime(); + G_switch_env(); /* -> input */ + for (int r = imin; r <= imax; r++) + Rast_get_row(fdi, + (unsigned char *)strip + + (size_t)(r - imin) * incellhd.cols * cell_size, + r, cell_type); + G_switch_env(); /* -> output */ + t_fill += omp_get_wtime() - t0; + } - double user_limit_mb = atof(memory->answer); - if (memory_mb > user_limit_mb) { - G_warning(_("Input map requires %.2f MB of RAM for parallel processing, " - "which exceeds the current memory limit (%.0f MB)."), - memory_mb, user_limit_mb); - G_important_message(_("The process may crash if system RAM is insufficient. " - "Increase the 'memory' option or use g.region to reduce the area.")); - } - /* Proceed with allocation */ - void *full_map_array = G_malloc(total_cells * cell_size); - - if (!full_map_array) - G_fatal_error("Insufficient RAM for bypass. Try a smaller region."); - - G_important_message(_("Loading map into RAM buffer for parallel bypass...")); - - /* 2. Sequential load (Single-threaded, library-safe) */ - /* ibuffer is already loaded by readcell, but we move it to a flat array - so we can access it lock-free without the readcell tile cache logic */ - for (int r = 0; r < incellhd.rows; r++) { - void *row_ptr = (void *)((unsigned char *)full_map_array + ((size_t)r * incellhd.cols * cell_size)); - /* We use the already-loaded cache here, but single-threaded */ - interpolate(ibuffer, row_ptr, cell_type, 0.0, (double)r, &incellhd); - G_percent(r, incellhd.rows - 1, 5); - } + /* Per-band output buffer, lock-free disjoint row slots (band-relative + * index), mirroring r.neighbors' outputs[i].buf. */ + void *band_out = + G_malloc((size_t)band_orows * outcellhd.cols * cell_size); - G_important_message(_("Projecting (Lock-Free Parallel)...")); - - #pragma omp parallel for private(row, col) schedule(dynamic) - for (row = 0; row < outcellhd.rows; row++) { - void *local_obuffer = Rast_allocate_output_buf(cell_type); - double local_y = outcellhd.north - (outcellhd.ns_res / 2) - (row * outcellhd.ns_res); - double local_x_start = outcellhd.west + (outcellhd.ew_res / 2); - - for (col = 0; col < outcellhd.cols; col++) { - void *obufptr = (void *)((unsigned char *)local_obuffer + (size_t)col * cell_size); - double x1 = local_x_start + (col * outcellhd.ew_res); - double y1 = local_y; - - if (GPJ_transform(&oproj, &iproj, &tproj, PJ_FWD, &x1, &y1, NULL) < 0) { - Rast_set_null_value(obufptr, 1, cell_type); - } else { - double c_idx = (x1 - incellhd.west) / incellhd.ew_res; - double r_idx = (incellhd.north - y1) / incellhd.ns_res; - - /* CALL OUR LOCK-FREE RAM INTERPOLATOR */ - interpolate_ram(full_map_array, obufptr, cell_type, c_idx, r_idx, &incellhd); + double t1 = omp_get_wtime(); +#pragma omp parallel + { + /* Per-thread PROJ context + private transform clone (KEEP: this is + * the bit-exact-verified, banding-agnostic part). oproj/iproj are + * 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(); + tproj_local.pj = proj_clone(thread_ctx, tproj.pj); + +#pragma omp for private(row, col) schedule(dynamic) + for (row = obr0; row < obr1; row++) { + void *out_row = + (unsigned char *)band_out + + (size_t)(row - obr0) * outcellhd.cols * cell_size; + double local_y = outcellhd.north - (outcellhd.ns_res / 2) - + (row * outcellhd.ns_res); + double local_x_start = outcellhd.west + (outcellhd.ew_res / 2); + + for (col = 0; col < outcellhd.cols; col++) { + void *obufptr = + (unsigned char *)out_row + (size_t)col * cell_size; + double x1 = local_x_start + (col * outcellhd.ew_res); + double y1 = local_y; + + if (GPJ_transform(&oproj, &iproj, &tproj_local, PJ_FWD, &x1, + &y1, NULL) < 0) { + Rast_set_null_value(obufptr, 1, cell_type); + } + else { + double c_idx = (x1 - incellhd.west) / incellhd.ew_res; + double r_idx = (incellhd.north - y1) / incellhd.ns_res; + interpolate_strip(strip, obufptr, cell_type, c_idx, + r_idx, &incellhd, imin, imax); + } + } } - } - #pragma omp critical - { - Rast_put_row(fdo, local_obuffer, cell_type); + proj_destroy(tproj_local.pj); + proj_context_destroy(thread_ctx); } - G_free(local_obuffer); + t_compute += omp_get_wtime() - t1; + + /* Serial in-order write of the band's rows (Rast_put_row sequential). + */ + double t2 = omp_get_wtime(); + for (row = obr0; row < obr1; row++) + Rast_put_row(fdo, + (unsigned char *)band_out + + (size_t)(row - obr0) * outcellhd.cols * cell_size, + cell_type); + t_write += omp_get_wtime() - t2; + + G_percent(obr1, outcellhd.rows, 5); + + if (strip) + G_free(strip); + G_free(band_out); + obr0 = obr1; } - /*RAM BYPASS END*/ + G_message("PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f " + "bands=%d", + t_size, t_fill, t_compute, t_write, n_bands); + + /* Close input map in its own env, then the output map. */ + G_switch_env(); /* -> input */ + Rast_close(fdi); + G_switch_env(); /* -> output */ Rast_close(fdo); - release_cache(ibuffer); - G_free(full_map_array); /* Clean up our bypass buffer */ if (have_colors > 0) { Rast_write_colors(mapname, G_mapset(), &colr); @@ -854,4 +990,3 @@ char *make_ipol_desc(void) return buf; } - From 6539be7350a06c4470fec5037d3c9eeb59a0c205 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Thu, 2 Jul 2026 00:09:18 -0700 Subject: [PATCH 04/32] r.proj: link libproj for direct proj_* calls --- raster/r.proj/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/raster/r.proj/Makefile b/raster/r.proj/Makefile index 083cd95c72f..147b47fe2d8 100644 --- a/raster/r.proj/Makefile +++ b/raster/r.proj/Makefile @@ -2,7 +2,7 @@ MODULE_TOPDIR = ../.. PGM = r.proj -LIBES = $(GPROJLIB) $(RASTERLIB) $(GISLIB) $(MATHLIB) $(PARSONLIB) +LIBES = $(GPROJLIB) $(RASTERLIB) $(GISLIB) $(MATHLIB) $(PARSONLIB) $(PROJLIB) DEPENDENCIES = $(GPROJDEP) $(RASTERDEP) $(GISDEP) EXTRA_LIBS = $(OPENMP_LIBPATH) $(OPENMP_LIB) From b15631a48b5032be6c9ffa6c8b9ca8b02d30358a Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Thu, 2 Jul 2026 18:49:36 -0700 Subject: [PATCH 05/32] r.proj: address review comments (remove stray PACKAGE define, clarify comments) --- raster/r.proj/main.c | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 7afc15e60e8..d4d181a6936 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -66,8 +66,6 @@ #include #include "r.proj.h" -#define PACKAGE "grassmods" - #include /* modify this table to add new methods */ @@ -104,8 +102,12 @@ static void interpolate_strip(void *strip, void *obufptr, int cell_type, return; } - /* Inside the map but outside the loaded strip: the span check under-sized - * the strip. This is a correctness failure, not a NULL. */ + /* This input row is inside the input map (the check above already handled + * coordinates that fall outside it), but it is not among the rows we + * preloaded into this band's strip. That cannot happen if the band's + * footprint estimate was right, so it means the estimate was wrong: a bug + * in band sizing, not a normal case. Fail loudly rather than write a NULL + * and silently produce wrong output. */ if (r < imin || r > imax) G_fatal_error(_("Band strip under-sized: input row %d outside loaded " "range [%d, %d] at column %d"), @@ -863,6 +865,14 @@ int main(int argc, char **argv) G_malloc((size_t)band_orows * outcellhd.cols * cell_size); double t1 = omp_get_wtime(); + /* Each band runs one parallel region. This is not nested parallelism: + * the "omp for" below does not create a second thread team, it only + * divides the band's output rows among the threads that this "omp + * parallel" created. The two directives are kept separate instead of a + * combined "omp parallel for" because every thread must clone its own + * PROJ context before the row loop starts and destroy it after the loop + * ends, and that per-thread setup has to sit inside the parallel region + * but outside the for. */ #pragma omp parallel { /* Per-thread PROJ context + private transform clone (KEEP: this is From 814d4e8dc49662cde88ad4e9d419f1690c161e09 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Tue, 7 Jul 2026 20:24:03 -0700 Subject: [PATCH 06/32] r.proj: guard OpenMP timer calls for non-OpenMP builds 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. --- raster/r.proj/main.c | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index d4d181a6936..5c126fd8ae0 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -66,7 +66,18 @@ #include #include "r.proj.h" +#ifdef _OPENMP #include +static inline double rproj_wtime(void) +{ + return omp_get_wtime(); +} +#else +static inline double rproj_wtime(void) +{ + return 0.0; +} +#endif /* modify this table to add new methods */ struct menu menu[] = { @@ -811,7 +822,7 @@ int main(int argc, char **argv) * strip plus the band's output buffer fit the cap. band_input_row_span * is RE-RUN for every candidate height -- the previous band's span is * never reused. */ - double ts = omp_get_wtime(); + double ts = rproj_wtime(); int band_orows = outcellhd.rows - obr0; int imin = 0, imax = -1; for (;;) { @@ -834,7 +845,7 @@ int main(int argc, char **argv) strip_rows, cap_mb); band_orows = (band_orows + 1) / 2; /* halve (round up), re-sample */ } - t_size += omp_get_wtime() - ts; + t_size += rproj_wtime() - ts; int obr1 = obr0 + band_orows; int strip_rows = imax - imin + 1; @@ -848,7 +859,7 @@ int main(int argc, char **argv) void *strip = NULL; if (strip_rows > 0) { strip = G_malloc((size_t)strip_rows * incellhd.cols * cell_size); - double t0 = omp_get_wtime(); + double t0 = rproj_wtime(); G_switch_env(); /* -> input */ for (int r = imin; r <= imax; r++) Rast_get_row(fdi, @@ -856,7 +867,7 @@ int main(int argc, char **argv) (size_t)(r - imin) * incellhd.cols * cell_size, r, cell_type); G_switch_env(); /* -> output */ - t_fill += omp_get_wtime() - t0; + t_fill += rproj_wtime() - t0; } /* Per-band output buffer, lock-free disjoint row slots (band-relative @@ -864,7 +875,7 @@ int main(int argc, char **argv) void *band_out = G_malloc((size_t)band_orows * outcellhd.cols * cell_size); - double t1 = omp_get_wtime(); + double t1 = rproj_wtime(); /* Each band runs one parallel region. This is not nested parallelism: * the "omp for" below does not create a second thread team, it only * divides the band's output rows among the threads that this "omp @@ -914,17 +925,17 @@ int main(int argc, char **argv) proj_destroy(tproj_local.pj); proj_context_destroy(thread_ctx); } - t_compute += omp_get_wtime() - t1; + t_compute += rproj_wtime() - t1; /* Serial in-order write of the band's rows (Rast_put_row sequential). */ - double t2 = omp_get_wtime(); + double t2 = rproj_wtime(); for (row = obr0; row < obr1; row++) Rast_put_row(fdo, (unsigned char *)band_out + (size_t)(row - obr0) * outcellhd.cols * cell_size, cell_type); - t_write += omp_get_wtime() - t2; + t_write += rproj_wtime() - t2; G_percent(obr1, outcellhd.rows, 5); @@ -934,9 +945,9 @@ int main(int argc, char **argv) obr0 = obr1; } - G_message("PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f " - "bands=%d", - t_size, t_fill, t_compute, t_write, n_bands); + G_debug(1, + "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f bands=%d", + t_size, t_fill, t_compute, t_write, n_bands); /* Close input map in its own env, then the output map. */ G_switch_env(); /* -> input */ From f01bd9ed89115fa670df1203b315805aff503a08 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 8 Jul 2026 19:26:09 -0700 Subject: [PATCH 07/32] lib/proj: add per-thread transform clone helpers 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. --- include/grass/defs/gprojects.h | 2 ++ include/grass/gprojects.h | 9 +++++++++ lib/proj/do_proj.c | 35 ++++++++++++++++++++++++++++++++++ raster/r.proj/main.c | 12 +++++------- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/include/grass/defs/gprojects.h b/include/grass/defs/gprojects.h index 1270ed66c5a..2f21ba91230 100644 --- a/include/grass/defs/gprojects.h +++ b/include/grass/defs/gprojects.h @@ -9,6 +9,8 @@ int GPJ_transform(const struct pj_info *, const struct pj_info *, int GPJ_transform_array(const struct pj_info *, const struct pj_info *, const struct pj_info *, int, double *, double *, double *, int); +void GPJ_clone_transform(const struct pj_info *, struct gpj_transform_clone *); +void GPJ_free_transform_clone(struct gpj_transform_clone *); /* old API, to be removed */ int pj_do_proj(double *, double *, const struct pj_info *, diff --git a/include/grass/gprojects.h b/include/grass/gprojects.h index 803eecf8477..8fc71b8f2a9 100644 --- a/include/grass/gprojects.h +++ b/include/grass/gprojects.h @@ -48,6 +48,15 @@ struct pj_info { char *wkt; }; +/* Per-thread clone of a transform, filled by GPJ_clone_transform() and + * released by GPJ_free_transform_clone(). Bundles the cloned transform with + * the private PROJ context it was cloned into, so ownership is a single unit. + */ +struct gpj_transform_clone { + struct pj_info info; + PJ_CONTEXT *ctx; +}; + struct gpj_datum { char *name, *longname, *ellps; double dx, dy, dz; diff --git a/lib/proj/do_proj.c b/lib/proj/do_proj.c index 22c635310cc..66be1c7e5ed 100644 --- a/lib/proj/do_proj.c +++ b/lib/proj/do_proj.c @@ -1414,3 +1414,38 @@ int pj_do_transform(int count, double *x, double *y, double *h, } return ok; } + +/*! + * \brief Clone a transform into a fresh per-thread PROJ context + * + * PROJ transformation objects are not safe for concurrent use, so each thread + * needs its own. This fills \p clone with a copy of \p src whose PJ is cloned + * into a new private context. Release it with GPJ_free_transform_clone(). + * + * Safe to call concurrently from multiple threads with the same \p src, + * provided \p src is not modified during the calls: each call clones into its + * own new context and touches no shared mutable state. + * + * \param src source transform (as set up by GPJ_init_transform()) + * \param[out] clone receives the per-thread clone (info plus private context) + */ +void GPJ_clone_transform(const struct pj_info *src, + struct gpj_transform_clone *clone) +{ + clone->ctx = proj_context_create(); + clone->info = *src; + clone->info.pj = proj_clone(clone->ctx, src->pj); +} + +/*! + * \brief Free a per-thread transform clone and its context + * + * \param clone clone filled by GPJ_clone_transform(); its cloned PJ is set to + * NULL after release + */ +void GPJ_free_transform_clone(struct gpj_transform_clone *clone) +{ + proj_destroy(clone->info.pj); + proj_context_destroy(clone->ctx); + clone->info.pj = NULL; +} diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 5c126fd8ae0..fec3a319ca9 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -890,9 +890,8 @@ int main(int argc, char **argv) * the bit-exact-verified, banding-agnostic part). oproj/iproj are * 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(); - tproj_local.pj = proj_clone(thread_ctx, tproj.pj); + struct gpj_transform_clone tproj_local; + GPJ_clone_transform(&tproj, &tproj_local); #pragma omp for private(row, col) schedule(dynamic) for (row = obr0; row < obr1; row++) { @@ -909,8 +908,8 @@ int main(int argc, char **argv) double x1 = local_x_start + (col * outcellhd.ew_res); double y1 = local_y; - if (GPJ_transform(&oproj, &iproj, &tproj_local, PJ_FWD, &x1, - &y1, NULL) < 0) { + if (GPJ_transform(&oproj, &iproj, &tproj_local.info, PJ_FWD, + &x1, &y1, NULL) < 0) { Rast_set_null_value(obufptr, 1, cell_type); } else { @@ -922,8 +921,7 @@ int main(int argc, char **argv) } } - proj_destroy(tproj_local.pj); - proj_context_destroy(thread_ctx); + GPJ_free_transform_clone(&tproj_local); } t_compute += rproj_wtime() - t1; From 76f3666ac7c05b6cd0a35da862e512ae6713e15c Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 8 Jul 2026 15:38:09 -0700 Subject: [PATCH 08/32] r.proj: experimental per-thread-fd parallel strip reads 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. --- raster/r.proj/main.c | 65 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index fec3a319ca9..13d90126c7a 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -784,6 +784,32 @@ int main(int argc, char **argv) cell_type = FCELL_TYPE; cell_size = Rast_cell_size(cell_type); + /* Parallel input reads: decide the read-thread count here, in the INPUT + * env, so the mask guard checks the source mapset's mask (the mask that + * would apply to Rast_get_row on fdi). Rast_disable_omp_on_mask returns 1 + * (serial) if a mask is present or without OpenMP, and does NOT touch the + * thread count when no mask exists (lib/raster/mask_info.c:226-231), so the + * compute region's threads are unperturbed in the common case. When + * read_nprocs > 1 we open that many FRESH read fds (one per thread); fdi is + * used only by the serial fallback. + * INFERRED-safe (not yet runtime-verified; the gate converts it): + * concurrent Rast_open_old fds on the same map across locations is sound + * from the r.neighbors same-location precedent (in_fd[t]) plus the Stage 1 + * fcb analysis (each fd carries its own cur_row/data/data_fd; reads depend + * only on the fcb and R__.rd_window). */ +#ifdef _OPENMP + int want_nprocs = omp_get_max_threads(); +#else + int want_nprocs = 1; +#endif + int read_nprocs = Rast_disable_omp_on_mask(want_nprocs); + int *fd_read = NULL; + if (read_nprocs > 1) { + fd_read = G_malloc((size_t)read_nprocs * sizeof(int)); + for (int t = 0; t < read_nprocs; t++) + fd_read[t] = Rast_open_old(inmap->answer, setname); + } + /* Back to the output location: set output window, init transform, open * output map. Both fds now stay open; rd_window/wr_window are set and * survive env switches, so reads/writes use the right windows throughout. @@ -861,11 +887,35 @@ int main(int argc, char **argv) strip = G_malloc((size_t)strip_rows * incellhd.cols * cell_size); double t0 = rproj_wtime(); G_switch_env(); /* -> input */ - for (int r = imin; r <= imax; r++) - Rast_get_row(fdi, - (unsigned char *)strip + - (size_t)(r - imin) * incellhd.cols * cell_size, - r, cell_type); + if (read_nprocs > 1) { +#ifdef _OPENMP + /* Parallel read: each thread reads a contiguous, disjoint + * block of strip rows (schedule(static)) through its OWN fd + * into its own disjoint strip slice (slice = row r - imin). + * No two threads share an fd or a strip row. */ +#pragma omp parallel num_threads(read_nprocs) + { + int t = omp_get_thread_num(); +#pragma omp for schedule(static) + for (int r = imin; r <= imax; r++) + Rast_get_row(fd_read[t], + (unsigned char *)strip + + (size_t)(r - imin) * incellhd.cols * + cell_size, + r, cell_type); + } +#endif + } + else { + /* Serial fallback (nprocs==1, mask present, or no OpenMP): + * original loop, unchanged, through fdi. */ + for (int r = imin; r <= imax; r++) + Rast_get_row(fdi, + (unsigned char *)strip + (size_t)(r - imin) * + incellhd.cols * + cell_size, + r, cell_type); + } G_switch_env(); /* -> output */ t_fill += rproj_wtime() - t0; } @@ -950,6 +1000,11 @@ int main(int argc, char **argv) /* Close input map in its own env, then the output map. */ G_switch_env(); /* -> input */ Rast_close(fdi); + if (fd_read) { + for (int t = 0; t < read_nprocs; t++) + Rast_close(fd_read[t]); + G_free(fd_read); + } G_switch_env(); /* -> output */ Rast_close(fdo); From afad44b3ae03e5e24945764cb8f35132634ec4b1 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Thu, 9 Jul 2026 23:24:42 -0700 Subject: [PATCH 09/32] r.proj: add adaptive column tiling for oblique reprojections 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. --- raster/r.proj/main.c | 388 +++++++++++++++++++++++++++++++------------ 1 file changed, 278 insertions(+), 110 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 13d90126c7a..e968b79c740 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -130,29 +130,30 @@ static void interpolate_strip(void *strip, void *obufptr, int cell_type, memcpy(obufptr, src, cell_size); } -/* Dense edge-walk of an output band's rectangle [obr0, obr1) projected into - * input space; returns the min/max INPUT ROW touched, plus a 2-cell margin, - * clamped to the input map. Samples the band's top and bottom rows across all - * columns and its left and right columns across all band rows (bordwalk-style), - * so a curved transform's interior-edge extremum is caught -- corner-only - * sampling can under-size the strip. Called serially, before the parallel - * region, so the shared tproj is safe here. Returns imax < imin for a band - * that projects entirely outside the input. */ +/* Dense edge-walk of an output tile's rectangle [obr0, obr1) x [obc0, obc1) + * projected into input space; returns the min/max INPUT ROW touched, plus a + * 2-cell margin, clamped to the input map. Samples the tile's top and bottom + * rows across its columns [obc0, obc1) and its left and right columns across + * its rows (bordwalk-style), so a curved transform's interior-edge extremum is + * caught -- corner-only sampling can under-size the strip. A full-width band is + * the case obc0=0, obc1=cols. Called serially, before the parallel region, so + * the shared tproj is safe here. Returns imax < imin for a tile that projects + * entirely outside the input. */ static void band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, int obr0, int obr1, - int *imin, int *imax) + int obc0, int obc1, int *imin, int *imax) { double rmin = 1e300, rmax = -1e300; int e, r, c; - /* top edge (row obr0) and bottom edge (row obr1-1), all columns */ + /* top edge (row obr0) and bottom edge (row obr1-1), tile columns */ for (e = 0; e < 2; e++) { int orow = (e == 0) ? obr0 : (obr1 - 1); double y = ohd->north - (orow + 0.5) * ohd->ns_res; - for (c = 0; c < ohd->cols; c++) { + for (c = obc0; c < obc1; c++) { double x = ohd->west + (c + 0.5) * ohd->ew_res; double xx = x, yy = y; if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &xx, &yy, NULL) < 0) @@ -164,9 +165,9 @@ static void band_input_row_span(const struct Cell_head *ohd, rmax = ri; } } - /* left edge (col 0) and right edge (col cols-1), all band rows */ + /* left edge (col obc0) and right edge (col obc1-1), all band rows */ for (e = 0; e < 2; e++) { - int ocol = (e == 0) ? 0 : (ohd->cols - 1); + int ocol = (e == 0) ? obc0 : (obc1 - 1); double x = ohd->west + (ocol + 0.5) * ohd->ew_res; for (r = obr0; r < obr1; r++) { double y = ohd->north - (r + 0.5) * ohd->ns_res; @@ -197,6 +198,75 @@ static void band_input_row_span(const struct Cell_head *ohd, *imax = hi; } +/* Largest input-row strip (in rows) among the column tiles of width tilew that + * partition output columns [0, ohd->cols) for the band [obr0, obr1). Tiles are + * loaded one at a time, so peak strip memory is set by the worst tile, not the + * union of the band's tiles; the fit search sizes this against the cap. Every + * call is a full tile edge-walk, so this is O(tiles * perimeter) -- paid in the + * serial size phase, and only when column splitting is actually entered. + * Returns 0 if every tile projects entirely outside the input. */ +static int worst_tile_strip_rows(const struct Cell_head *ohd, + const struct Cell_head *ihd, + const struct pj_info *oproj, + const struct pj_info *iproj, + const struct pj_info *tproj, int obr0, + int obr1, int tilew) +{ + int worst = 0, obc0; + + for (obc0 = 0; obc0 < ohd->cols; obc0 += tilew) { + int obc1 = obc0 + tilew; + int imin, imax, rows; + + if (obc1 > ohd->cols) + obc1 = ohd->cols; + band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr1, obc0, + obc1, &imin, &imax); + rows = imax - imin + 1; /* imax < imin (empty) -> <= 0, ignored */ + if (rows > worst) + worst = rows; + } + return worst; +} + +#define TILE_PROBE 16 /* tiles sampled by the Phase-2 width-search estimate */ + +/* Cheap estimate of worst_tile_strip_rows: the largest input-row strip among + * at most `probe` column tiles, evenly spaced across the band width and always + * including the first and last. A subset max is a LOWER bound on the true + * worst, so it only PRUNES the Phase-2 search; the chosen width is exact- + * validated by worst_tile_strip_rows before use. */ +static int est_worst_tile_strip_rows(const struct Cell_head *ohd, + const struct Cell_head *ihd, + const struct pj_info *oproj, + const struct pj_info *iproj, + const struct pj_info *tproj, int obr0, + int obr1, int tilew, int probe) +{ + int ntiles = (ohd->cols + tilew - 1) / tilew; + int worst = 0, k; + + if (probe < 1) + probe = 1; + if (probe > ntiles) + probe = ntiles; + for (k = 0; k < probe; k++) { + int ti = (probe == 1) ? 0 : (int)((long)k * (ntiles - 1) / (probe - 1)); + int obc0 = ti * tilew; + int obc1 = obc0 + tilew; + int imin, imax, rows; + + if (obc1 > ohd->cols) + obc1 = ohd->cols; + band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr1, obc0, + obc1, &imin, &imax); + rows = imax - imin + 1; + if (rows > worst) + worst = rows; + } + return worst; +} + int main(int argc, char **argv) { char *mapname, /* ptr to name of output layer */ @@ -839,21 +909,28 @@ int main(int argc, char **argv) size_t cap_bytes = (size_t)(cap_mb * 1024.0 * 1024.0); double t_size = 0.0, t_fill = 0.0, t_compute = 0.0, t_write = 0.0; int n_bands = 0; + int max_tiles = 1; /* most column tiles used by any single band */ G_important_message(_("Projecting (banded, per-thread PROJ context)...")); int obr0 = 0; while (obr0 < outcellhd.rows) { - /* Band height: start from all remaining rows, halve until the input - * strip plus the band's output buffer fit the cap. band_input_row_span - * is RE-RUN for every candidate height -- the previous band's span is - * never reused. */ + /* Fit search. Phase 1 (fast path, unchanged): halve the band height + * until the FULL-WIDTH strip plus the band output buffer fit the cap; + * the span is re-run per candidate height. Phase 2 (oblique fallback): + * only if a single full-width output row still busts the cap, split the + * row into column tiles and halve tile WIDTH until the worst tile's + * strip fits. Strips are full input width (the raster API reads whole + * rows), so width splitting shrinks a tile's input ROW span, not its + * width. Easy pairs never leave Phase 1. */ double ts = rproj_wtime(); int band_orows = outcellhd.rows - obr0; + int tilew = outcellhd.cols; int imin = 0, imax = -1; for (;;) { band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - obr0, obr0 + band_orows, &imin, &imax); + obr0, obr0 + band_orows, 0, outcellhd.cols, + &imin, &imax); int strip_rows = imax - imin + 1; size_t strip_bytes = strip_rows > 0 ? (size_t)strip_rows * incellhd.cols * cell_size @@ -862,121 +939,213 @@ int main(int argc, char **argv) if (strip_bytes + out_bytes <= cap_bytes) break; if (band_orows == 1) - G_fatal_error( - _("A single output row needs %.1f MB (input footprint %d " - "rows), exceeding the memory cap (%.1f MB). This " - "large-halo/oblique case needs the tile-cache path, " - "which is not implemented."), - (double)(strip_bytes + out_bytes) / (1024.0 * 1024.0), - strip_rows, cap_mb); + break; /* height exhausted: fall through to column splitting */ band_orows = (band_orows + 1) / 2; /* halve (round up), re-sample */ } + if (band_orows == 1) { + /* Phase 2 (oblique only): Phase 1 could not fit even a single + * full-width row, so prefer a TALL tiled band instead of a 1-row + * one. Rescan from the full remaining height downward; at the + * tallest height whose output buffer fits the cap, halve tile WIDTH + * until the worst column tile's strip fits, and only reduce height + * when no width fits. Keeping the band tall gives the per-band + * parallel region many output rows. Runs only on this path, so the + * easy-pair size phase (Phase 1) is unaffected. */ + band_orows = outcellhd.rows - obr0; + for (;;) { + size_t out_bytes = + (size_t)band_orows * outcellhd.cols * cell_size; + if (out_bytes <= cap_bytes) { + /* Upper tier: cheap probe estimate narrows to a candidate + * width, checking down to tilew==1. The estimate is a lower + * bound, so est-no-fit at tilew==1 implies exact-no-fit -> + * the exact validation below is skipped entirely at heights + * where no width can fit (this is what keeps the search + * cheap; scanning every tile there was the cost). */ + tilew = outcellhd.cols; + int est_fit = 0; + for (;;) { + int est = est_worst_tile_strip_rows( + &outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, + obr0 + band_orows, tilew, TILE_PROBE); + size_t est_bytes = + est > 0 ? (size_t)est * incellhd.cols * cell_size + : 0; + if (est_bytes + out_bytes <= cap_bytes) { + est_fit = 1; + break; + } + if (tilew == 1) + break; + tilew = (tilew + 1) / 2; + } + /* Lower tier: EXACT validation, only when the estimate + * found a candidate. Narrow and re-validate if the estimate + * was optimistic; this exact-sizes the accepted width so + * the cap is honored. */ + int fit = 0; + if (est_fit) { + for (;;) { + int worst = worst_tile_strip_rows( + &outcellhd, &incellhd, &oproj, &iproj, &tproj, + obr0, obr0 + band_orows, tilew); + size_t strip_bytes = + worst > 0 + ? (size_t)worst * incellhd.cols * cell_size + : 0; + if (strip_bytes + out_bytes <= cap_bytes) { + fit = 1; + break; + } + if (tilew == 1) + break; /* exact: no width fits at this height */ + tilew = (tilew + 1) / 2; + } + } + if (fit) + break; + } + if (band_orows == 1) { + /* Single output row at minimum width still over cap = + * singular/large-halo; needs the tile-cache path. */ + size_t out1 = (size_t)outcellhd.cols * cell_size; + int worst = worst_tile_strip_rows(&outcellhd, &incellhd, + &oproj, &iproj, &tproj, + obr0, obr0 + 1, 1); + size_t strip_bytes = + worst > 0 ? (size_t)worst * incellhd.cols * cell_size + : 0; + G_fatal_error( + _("A single output row needs %.1f MB (input footprint " + "%d rows), exceeding the memory cap (%.1f MB). This " + "large-halo/oblique case needs the tile-cache path, " + "which is not implemented."), + (double)(strip_bytes + out1) / (1024.0 * 1024.0), worst, + cap_mb); + } + band_orows = (band_orows + 1) / 2; /* shrink height, retry */ + } + } t_size += rproj_wtime() - ts; int obr1 = obr0 + band_orows; - int strip_rows = imax - imin + 1; n_bands++; + int n_tiles = (outcellhd.cols + tilew - 1) / tilew; + if (n_tiles > max_tiles) + max_tiles = n_tiles; - /* Serial strip load (single fd -> get_row not thread-safe). Reads in - * the INPUT env (matching the serial code's invariant), then back to - * OUTPUT for compute+write. EMPTY BAND: strip_rows <= 0 means the band - * projects entirely outside the input -> no malloc, no read; its cells - * become NULL via interpolate_strip's out-of-map path. */ - void *strip = NULL; - if (strip_rows > 0) { - strip = G_malloc((size_t)strip_rows * incellhd.cols * cell_size); - double t0 = rproj_wtime(); - G_switch_env(); /* -> input */ - if (read_nprocs > 1) { + /* Per-band output buffer, lock-free disjoint row slots, filled column + * tile by column tile and written once after all tiles. Full width + * regardless of tiling. */ + void *band_out = + G_malloc((size_t)band_orows * outcellhd.cols * cell_size); + + /* Column tiles processed one at a time: only the current tile's strip + * is resident, so peak strip memory is the worst tile, not the band's + * union. tilew == cols is the single-tile fast path (obc0=0, + * obc1=cols), identical to un-tiled banding. */ + for (int obc0 = 0; obc0 < outcellhd.cols; obc0 += tilew) { + int obc1 = obc0 + tilew; + if (obc1 > outcellhd.cols) + obc1 = outcellhd.cols; + + /* Per-tile input row span (full-width strip: the raster API reads + * whole rows, so columns are not cropped). */ + band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + obr0, obr1, obc0, obc1, &imin, &imax); + int strip_rows = imax - imin + 1; + + /* Serial strip load (single fd -> get_row not thread-safe). EMPTY + * TILE: strip_rows <= 0 -> projects outside input, no read; cells + * become NULL via interpolate_strip's out-of-map path. */ + void *strip = NULL; + if (strip_rows > 0) { + strip = + G_malloc((size_t)strip_rows * incellhd.cols * cell_size); + double t0 = rproj_wtime(); + G_switch_env(); /* -> input */ + if (read_nprocs > 1) { #ifdef _OPENMP - /* Parallel read: each thread reads a contiguous, disjoint - * block of strip rows (schedule(static)) through its OWN fd - * into its own disjoint strip slice (slice = row r - imin). - * No two threads share an fd or a strip row. */ + /* Parallel read: each thread reads a contiguous, disjoint + * block of strip rows through its OWN fd into its own + * disjoint strip slice. No two threads share an fd/row. */ #pragma omp parallel num_threads(read_nprocs) - { - int t = omp_get_thread_num(); + { + int t = omp_get_thread_num(); #pragma omp for schedule(static) + for (int r = imin; r <= imax; r++) + Rast_get_row(fd_read[t], + (unsigned char *)strip + + (size_t)(r - imin) * + incellhd.cols * cell_size, + r, cell_type); + } +#endif + } + else { + /* Serial fallback (nprocs==1, mask, or no OpenMP). */ for (int r = imin; r <= imax; r++) - Rast_get_row(fd_read[t], + Rast_get_row(fdi, (unsigned char *)strip + (size_t)(r - imin) * incellhd.cols * cell_size, r, cell_type); } -#endif + G_switch_env(); /* -> output */ + t_fill += rproj_wtime() - t0; } - else { - /* Serial fallback (nprocs==1, mask present, or no OpenMP): - * original loop, unchanged, through fdi. */ - for (int r = imin; r <= imax; r++) - Rast_get_row(fdi, - (unsigned char *)strip + (size_t)(r - imin) * - incellhd.cols * - cell_size, - r, cell_type); - } - G_switch_env(); /* -> output */ - t_fill += rproj_wtime() - t0; - } - - /* Per-band output buffer, lock-free disjoint row slots (band-relative - * index), mirroring r.neighbors' outputs[i].buf. */ - void *band_out = - G_malloc((size_t)band_orows * outcellhd.cols * cell_size); - double t1 = rproj_wtime(); - /* Each band runs one parallel region. This is not nested parallelism: - * the "omp for" below does not create a second thread team, it only - * divides the band's output rows among the threads that this "omp - * parallel" created. The two directives are kept separate instead of a - * combined "omp parallel for" because every thread must clone its own - * PROJ context before the row loop starts and destroy it after the loop - * ends, and that per-thread setup has to sit inside the parallel region - * but outside the for. */ + double t1 = rproj_wtime(); + /* One parallel region per tile. Not nested: the "omp for" divides + * the band's output rows among this region's threads. Separate + * directives so each thread clones its PROJ context before the row + * loop and destroys it after. */ #pragma omp parallel - { - /* Per-thread PROJ context + private transform clone (KEEP: this is - * the bit-exact-verified, banding-agnostic part). oproj/iproj are - * read-only shared; the static METERS_in/out race is benign - * (constant CRS per run). */ - struct gpj_transform_clone tproj_local; - GPJ_clone_transform(&tproj, &tproj_local); + { + struct gpj_transform_clone tproj_local; + GPJ_clone_transform(&tproj, &tproj_local); #pragma omp for private(row, col) schedule(dynamic) - for (row = obr0; row < obr1; row++) { - void *out_row = - (unsigned char *)band_out + - (size_t)(row - obr0) * outcellhd.cols * cell_size; - double local_y = outcellhd.north - (outcellhd.ns_res / 2) - - (row * outcellhd.ns_res); - double local_x_start = outcellhd.west + (outcellhd.ew_res / 2); - - for (col = 0; col < outcellhd.cols; col++) { - void *obufptr = - (unsigned char *)out_row + (size_t)col * cell_size; - double x1 = local_x_start + (col * outcellhd.ew_res); - double y1 = local_y; - - if (GPJ_transform(&oproj, &iproj, &tproj_local.info, PJ_FWD, - &x1, &y1, NULL) < 0) { - Rast_set_null_value(obufptr, 1, cell_type); - } - else { - double c_idx = (x1 - incellhd.west) / incellhd.ew_res; - double r_idx = (incellhd.north - y1) / incellhd.ns_res; - interpolate_strip(strip, obufptr, cell_type, c_idx, - r_idx, &incellhd, imin, imax); + for (row = obr0; row < obr1; row++) { + void *out_row = + (unsigned char *)band_out + + (size_t)(row - obr0) * outcellhd.cols * cell_size; + double local_y = outcellhd.north - (outcellhd.ns_res / 2) - + (row * outcellhd.ns_res); + double local_x_start = + outcellhd.west + (outcellhd.ew_res / 2); + + for (col = obc0; col < obc1; col++) { + void *obufptr = + (unsigned char *)out_row + (size_t)col * cell_size; + double x1 = local_x_start + (col * outcellhd.ew_res); + double y1 = local_y; + + if (GPJ_transform(&oproj, &iproj, &tproj_local.info, + PJ_FWD, &x1, &y1, NULL) < 0) { + Rast_set_null_value(obufptr, 1, cell_type); + } + else { + double c_idx = + (x1 - incellhd.west) / incellhd.ew_res; + double r_idx = + (incellhd.north - y1) / incellhd.ns_res; + interpolate_strip(strip, obufptr, cell_type, c_idx, + r_idx, &incellhd, imin, imax); + } } } + + GPJ_free_transform_clone(&tproj_local); } + t_compute += rproj_wtime() - t1; - GPJ_free_transform_clone(&tproj_local); + if (strip) + G_free(strip); } - t_compute += rproj_wtime() - t1; - /* Serial in-order write of the band's rows (Rast_put_row sequential). - */ + /* Serial in-order write of the band's rows once all tiles filled + * band_out (Rast_put_row sequential). */ double t2 = rproj_wtime(); for (row = obr0; row < obr1; row++) Rast_put_row(fdo, @@ -987,15 +1156,14 @@ int main(int argc, char **argv) G_percent(obr1, outcellhd.rows, 5); - if (strip) - G_free(strip); G_free(band_out); obr0 = obr1; } G_debug(1, - "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f bands=%d", - t_size, t_fill, t_compute, t_write, n_bands); + "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f bands=%d " + "tiles=%d", + t_size, t_fill, t_compute, t_write, n_bands, max_tiles); /* Close input map in its own env, then the output map. */ G_switch_env(); /* -> input */ From 0ba1455ff8568ecd2516d8cbd1a885380ff7cde8 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Mon, 13 Jul 2026 12:57:41 -0700 Subject: [PATCH 10/32] r.proj: reuse previous band size in the tile sizing search 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. --- raster/r.proj/main.c | 210 +++++++++++++++++++++++++++++-------------- 1 file changed, 144 insertions(+), 66 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index e968b79c740..682d64f7289 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -267,6 +267,78 @@ static int est_worst_tile_strip_rows(const struct Cell_head *ohd, return worst; } +/* Exact per-height fit test for the Phase-2 height search: 1 iff a band of + * height h at obr0 has an output buffer within the cap AND some column-tile + * width whose worst input strip fits (setting *acc_tilew to that width, via the + * same upper-tier estimate then lower-tier exact validation the search uses); + * 0 if no width fits or the output buffer alone exceeds the cap. */ +static int phase2_width_fit(const struct Cell_head *ohd, + const struct Cell_head *ihd, + const struct pj_info *oproj, + const struct pj_info *iproj, + const struct pj_info *tproj, int obr0, int h, + size_t cap_bytes, int cell_size, int *acc_tilew) +{ + size_t out_bytes = (size_t)h * ohd->cols * cell_size; + int tilew, est_fit; + + if (out_bytes > cap_bytes) + return 0; + tilew = ohd->cols; + est_fit = 0; + for (;;) { + int est = est_worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, obr0, + obr0 + h, tilew, TILE_PROBE); + size_t est_bytes = est > 0 ? (size_t)est * ihd->cols * cell_size : 0; + if (est_bytes + out_bytes <= cap_bytes) { + est_fit = 1; + break; + } + if (tilew == 1) + break; + tilew = (tilew + 1) / 2; + } + if (est_fit) { + for (;;) { + int worst = worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, + obr0, obr0 + h, tilew); + size_t strip_bytes = + worst > 0 ? (size_t)worst * ihd->cols * cell_size : 0; + if (strip_bytes + out_bytes <= cap_bytes) { + *acc_tilew = tilew; + return 1; + } + if (tilew == 1) + break; + tilew = (tilew + 1) / 2; + } + } + return 0; +} + +/* Full-width fit test for the Phase-1 height search: 1 iff a band of height h + * at obr0 has its full-width input strip plus output buffer within the cap. + * Short-circuits on the output buffer alone (no edge walk) when it already + * exceeds the cap. Used only by the seed peek; the walk keeps its inline test, + * so the miss path is byte-for-byte today's execution. */ +static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, int obr0, int h, + size_t cap_bytes, int cell_size) +{ + int imin, imax, strip_rows; + size_t out_bytes = (size_t)h * ohd->cols * cell_size, strip_bytes; + + if (out_bytes > cap_bytes) + return 0; + band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr0 + h, 0, + ohd->cols, &imin, &imax); + strip_rows = imax - imin + 1; + strip_bytes = + strip_rows > 0 ? (size_t)strip_rows * ihd->cols * cell_size : 0; + return strip_bytes + out_bytes <= cap_bytes; +} + int main(int argc, char **argv) { char *mapname, /* ptr to name of output layer */ @@ -909,7 +981,11 @@ int main(int argc, char **argv) size_t cap_bytes = (size_t)(cap_mb * 1024.0 * 1024.0); double t_size = 0.0, t_fill = 0.0, t_compute = 0.0, t_write = 0.0; int n_bands = 0; - int max_tiles = 1; /* most column tiles used by any single band */ + int max_tiles = 1; /* most column tiles used by any single band */ + int seed_h = 0, seed_w = 0; /* previous Phase-2 band's accepted sizing */ + int seed_hits = 0, phase2_bands = 0; /* seed hit rate on the Phase-2 path */ + int seed_h1 = 0; /* previous Phase-1 band's accepted height */ + int p1_hits = 0, p1_bands = 0; /* seed hit rate on the Phase-1 path */ G_important_message(_("Projecting (banded, per-thread PROJ context)...")); @@ -924,9 +1000,31 @@ int main(int argc, char **argv) * rows), so width splitting shrinks a tile's input ROW span, not its * width. Easy pairs never leave Phase 1. */ double ts = rproj_wtime(); - int band_orows = outcellhd.rows - obr0; int tilew = outcellhd.cols; int imin = 0, imax = -1; + /* Phase-1 neighbor seed (hit path): seed_h1 (previous Phase-1 accepted + * height) is close to this band's. Take g_seed, the grid height just + * ABOVE seed_h1 on this band's descending lattice; if it does not fit + * then (height-monotone span) nothing taller fits, so the tallest + * fitting height is at or below (g_seed+1)/2 and the walk can start + * there, skipping the tall full-width edge walks. Any miss (no seed, + * seed_h1 too tall, or g_seed fits) starts from the full remaining + * height -- byte-for-byte the walk below. Same lattice, same acceptance + * line -> identical accepted height and partition; the hit only skips + * heights it has shown cannot fit. */ + int band_orows = outcellhd.rows - obr0; + int p1_seeded = 0; + if (seed_h1 > 0 && seed_h1 < band_orows) { + int gs = band_orows; + + while ((gs + 1) / 2 > seed_h1) + gs = (gs + 1) / 2; + if (!phase1_fits(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + obr0, gs, cap_bytes, cell_size)) { + band_orows = (gs + 1) / 2; + p1_seeded = 1; + } + } for (;;) { band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, obr0 + band_orows, 0, outcellhd.cols, @@ -942,69 +1040,46 @@ int main(int argc, char **argv) break; /* height exhausted: fall through to column splitting */ band_orows = (band_orows + 1) / 2; /* halve (round up), re-sample */ } + if (band_orows > 1) { /* Phase-1 accepted a full-width band */ + seed_h1 = band_orows; + p1_bands++; + if (p1_seeded) + p1_hits++; + } if (band_orows == 1) { - /* Phase 2 (oblique only): Phase 1 could not fit even a single - * full-width row, so prefer a TALL tiled band instead of a 1-row - * one. Rescan from the full remaining height downward; at the - * tallest height whose output buffer fits the cap, halve tile WIDTH - * until the worst column tile's strip fits, and only reduce height - * when no width fits. Keeping the band tall gives the per-band - * parallel region many output rows. Runs only on this path, so the - * easy-pair size phase (Phase 1) is unaffected. */ - band_orows = outcellhd.rows - obr0; - for (;;) { - size_t out_bytes = - (size_t)band_orows * outcellhd.cols * cell_size; - if (out_bytes <= cap_bytes) { - /* Upper tier: cheap probe estimate narrows to a candidate - * width, checking down to tilew==1. The estimate is a lower - * bound, so est-no-fit at tilew==1 implies exact-no-fit -> - * the exact validation below is skipped entirely at heights - * where no width can fit (this is what keeps the search - * cheap; scanning every tile there was the cost). */ - tilew = outcellhd.cols; - int est_fit = 0; - for (;;) { - int est = est_worst_tile_strip_rows( - &outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, - obr0 + band_orows, tilew, TILE_PROBE); - size_t est_bytes = - est > 0 ? (size_t)est * incellhd.cols * cell_size - : 0; - if (est_bytes + out_bytes <= cap_bytes) { - est_fit = 1; - break; - } - if (tilew == 1) - break; - tilew = (tilew + 1) / 2; - } - /* Lower tier: EXACT validation, only when the estimate - * found a candidate. Narrow and re-validate if the estimate - * was optimistic; this exact-sizes the accepted width so - * the cap is honored. */ - int fit = 0; - if (est_fit) { - for (;;) { - int worst = worst_tile_strip_rows( - &outcellhd, &incellhd, &oproj, &iproj, &tproj, - obr0, obr0 + band_orows, tilew); - size_t strip_bytes = - worst > 0 - ? (size_t)worst * incellhd.cols * cell_size - : 0; - if (strip_bytes + out_bytes <= cap_bytes) { - fit = 1; - break; - } - if (tilew == 1) - break; /* exact: no width fits at this height */ - tilew = (tilew + 1) / 2; - } - } - if (fit) - break; + /* Phase 2 (oblique only): find the tallest band height on the + * descending grid whose worst column tile fits the cap, then that + * height's widest fitting tile width. Neighbor seed (hit path): the + * previous Phase-2 band's height (seed_h) is close to this band's + * H*. Take g_seed, the grid height just ABOVE seed_h; if it does + * not fit then (for an input-row span monotone in band height) + * nothing taller fits, so H* is at or below g_seed and the walk can + * start there, skipping the tall no-fit heights. On a miss (no + * seed, seed_h too tall, or g_seed fits) start from the full + * remaining height -- byte-for-byte the unseeded walk. Both starts + * lie on the same grid and accept via the same phase2_width_fit, so + * H*, W* and the partition are identical; the hit path only skips + * heights it has shown cannot fit. */ + phase2_bands++; + int start_h = outcellhd.rows - obr0; + if (seed_w > 0 && seed_h < start_h) { + int gs = start_h, w; + + while ((gs + 1) / 2 > seed_h) + gs = (gs + 1) / 2; + if (!phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, + &tproj, obr0, gs, cap_bytes, cell_size, + &w)) { + start_h = (gs + 1) / 2; + seed_hits++; } + } + band_orows = start_h; + for (;;) { + if (phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, + &tproj, obr0, band_orows, cap_bytes, + cell_size, &tilew)) + break; if (band_orows == 1) { /* Single output row at minimum width still over cap = * singular/large-halo; needs the tile-cache path. */ @@ -1023,8 +1098,10 @@ int main(int argc, char **argv) (double)(strip_bytes + out1) / (1024.0 * 1024.0), worst, cap_mb); } - band_orows = (band_orows + 1) / 2; /* shrink height, retry */ + band_orows = (band_orows + 1) / 2; } + seed_h = band_orows; + seed_w = tilew; } t_size += rproj_wtime() - ts; @@ -1162,8 +1239,9 @@ int main(int argc, char **argv) G_debug(1, "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f bands=%d " - "tiles=%d", - t_size, t_fill, t_compute, t_write, n_bands, max_tiles); + "tiles=%d seed_hits=%d phase2_bands=%d p1_hits=%d p1_bands=%d", + t_size, t_fill, t_compute, t_write, n_bands, max_tiles, seed_hits, + phase2_bands, p1_hits, p1_bands); /* Close input map in its own env, then the output map. */ G_switch_env(); /* -> input */ From de3ce06e5631c28796511054f9595cd956759fb0 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Tue, 14 Jul 2026 15:23:21 -0700 Subject: [PATCH 11/32] r.proj: size the input strip for tiles containing a pole 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. --- raster/r.proj/main.c | 135 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 28 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 682d64f7289..9d59f4e9f56 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -130,6 +130,21 @@ static void interpolate_strip(void *strip, void *obufptr, int cell_type, memcpy(obufptr, src, cell_size); } +/* Geographic poles within the input map's latitude coverage. + * band_input_row_span samples only the tile perimeter, so a tile whose interior + * holds a pole has an input-row (latitude) extremum the perimeter misses; the + * pole's row is folded into that tile's span. Each pole is stored as its + * output-CRS coordinate (for a point-in-tile test) and its input row. Filled + * once per map, and empty (n == 0) whenever no pole is in frame, so pole + * handling is a no-op on such maps. Assumes a pole maps to a single output + * point (azimuthal/stereographic); for a projection that images a pole as a + * line or arc, the under-size guard remains the backstop. */ +struct pole_set { + int n; /* active poles, 0..2 */ + double ox[2], oy[2]; /* pole coordinates in the output CRS */ + double ri[2]; /* pole input row index */ +}; + /* Dense edge-walk of an output tile's rectangle [obr0, obr1) x [obc0, obc1) * projected into input space; returns the min/max INPUT ROW touched, plus a * 2-cell margin, clamped to the input map. Samples the tile's top and bottom @@ -144,7 +159,8 @@ static void band_input_row_span(const struct Cell_head *ohd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, int obr0, int obr1, - int obc0, int obc1, int *imin, int *imax) + int obc0, int obc1, int *imin, int *imax, + const struct pole_set *poles, int *pole_widened) { double rmin = 1e300, rmax = -1e300; int e, r, c; @@ -182,6 +198,32 @@ static void band_input_row_span(const struct Cell_head *ohd, } } + /* Fold in any pole whose output point lies in this tile's rect: the + * perimeter walk cannot see an interior latitude extremum. A pole exactly + * on a tile edge (inclusive test) is caught by both adjacent tiles, which + * is harmless -- it only widens a strip that is loaded anyway. Placed + * before the empty-tile check so a pole inside an otherwise-outside tile + * still yields a valid span. */ + if (poles) { + double x_lo = ohd->west + obc0 * ohd->ew_res; + double x_hi = ohd->west + obc1 * ohd->ew_res; + double y_lo = ohd->north - obr1 * ohd->ns_res; + double y_hi = ohd->north - obr0 * ohd->ns_res; + int k; + + for (k = 0; k < poles->n; k++) { + if (poles->ox[k] < x_lo || poles->ox[k] > x_hi || + poles->oy[k] < y_lo || poles->oy[k] > y_hi) + continue; + if (poles->ri[k] < rmin) + rmin = poles->ri[k]; + if (poles->ri[k] > rmax) + rmax = poles->ri[k]; + if (pole_widened) + *pole_widened = k + 1; /* 1-based pole index, 0 == none */ + } + } + if (rmax < rmin) { /* band projects entirely outside the input */ *imin = 0; *imax = -1; @@ -205,12 +247,11 @@ static void band_input_row_span(const struct Cell_head *ohd, * call is a full tile edge-walk, so this is O(tiles * perimeter) -- paid in the * serial size phase, and only when column splitting is actually entered. * Returns 0 if every tile projects entirely outside the input. */ -static int worst_tile_strip_rows(const struct Cell_head *ohd, - const struct Cell_head *ihd, - const struct pj_info *oproj, - const struct pj_info *iproj, - const struct pj_info *tproj, int obr0, - int obr1, int tilew) +static int +worst_tile_strip_rows(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, int obr0, int obr1, + int tilew, const struct pole_set *poles) { int worst = 0, obc0; @@ -221,7 +262,7 @@ static int worst_tile_strip_rows(const struct Cell_head *ohd, if (obc1 > ohd->cols) obc1 = ohd->cols; band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr1, obc0, - obc1, &imin, &imax); + obc1, &imin, &imax, poles, NULL); rows = imax - imin + 1; /* imax < imin (empty) -> <= 0, ignored */ if (rows > worst) worst = rows; @@ -241,7 +282,8 @@ static int est_worst_tile_strip_rows(const struct Cell_head *ohd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, int obr0, - int obr1, int tilew, int probe) + int obr1, int tilew, int probe, + const struct pole_set *poles) { int ntiles = (ohd->cols + tilew - 1) / tilew; int worst = 0, k; @@ -259,7 +301,7 @@ static int est_worst_tile_strip_rows(const struct Cell_head *ohd, if (obc1 > ohd->cols) obc1 = ohd->cols; band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr1, obc0, - obc1, &imin, &imax); + obc1, &imin, &imax, poles, NULL); rows = imax - imin + 1; if (rows > worst) worst = rows; @@ -272,12 +314,11 @@ static int est_worst_tile_strip_rows(const struct Cell_head *ohd, * width whose worst input strip fits (setting *acc_tilew to that width, via the * same upper-tier estimate then lower-tier exact validation the search uses); * 0 if no width fits or the output buffer alone exceeds the cap. */ -static int phase2_width_fit(const struct Cell_head *ohd, - const struct Cell_head *ihd, - const struct pj_info *oproj, - const struct pj_info *iproj, - const struct pj_info *tproj, int obr0, int h, - size_t cap_bytes, int cell_size, int *acc_tilew) +static int +phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, int obr0, int h, size_t cap_bytes, + int cell_size, int *acc_tilew, const struct pole_set *poles) { size_t out_bytes = (size_t)h * ohd->cols * cell_size; int tilew, est_fit; @@ -288,7 +329,7 @@ static int phase2_width_fit(const struct Cell_head *ohd, est_fit = 0; for (;;) { int est = est_worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, obr0, - obr0 + h, tilew, TILE_PROBE); + obr0 + h, tilew, TILE_PROBE, poles); size_t est_bytes = est > 0 ? (size_t)est * ihd->cols * cell_size : 0; if (est_bytes + out_bytes <= cap_bytes) { est_fit = 1; @@ -301,7 +342,7 @@ static int phase2_width_fit(const struct Cell_head *ohd, if (est_fit) { for (;;) { int worst = worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, - obr0, obr0 + h, tilew); + obr0, obr0 + h, tilew, poles); size_t strip_bytes = worst > 0 ? (size_t)worst * ihd->cols * cell_size : 0; if (strip_bytes + out_bytes <= cap_bytes) { @@ -324,7 +365,8 @@ static int phase2_width_fit(const struct Cell_head *ohd, static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, int obr0, int h, - size_t cap_bytes, int cell_size) + size_t cap_bytes, int cell_size, + const struct pole_set *poles) { int imin, imax, strip_rows; size_t out_bytes = (size_t)h * ohd->cols * cell_size, strip_bytes; @@ -332,7 +374,7 @@ static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, if (out_bytes > cap_bytes) return 0; band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr0 + h, 0, - ohd->cols, &imin, &imax); + ohd->cols, &imin, &imax, poles, NULL); strip_rows = imax - imin + 1; strip_bytes = strip_rows > 0 ? (size_t)strip_rows * ihd->cols * cell_size : 0; @@ -987,6 +1029,35 @@ int main(int argc, char **argv) int seed_h1 = 0; /* previous Phase-1 band's accepted height */ int p1_hits = 0, p1_bands = 0; /* seed hit rate on the Phase-1 path */ + /* Pole footprint fix: a tile whose interior holds a geographic pole has an + * input-row extremum the perimeter walk misses. Precompute each in-range + * pole's output coordinate and input row (lat/lon input only, where a pole + * is at latitude +/- 90). On transform failure or a non-finite result the + * pole is skipped and the strip under-size guard stays the backstop. Uses + * the adjusted incellhd, matching what band_input_row_span sees. */ + struct pole_set poles; + + poles.n = 0; + if (incellhd.proj == PROJECTION_LL) { + double polelat[2] = {90.0, -90.0}; + + for (int p = 0; p < 2; p++) { + double px = 0.0, py = polelat[p]; + + if (polelat[p] > incellhd.north + 0.5 * incellhd.ns_res || + polelat[p] < incellhd.south - 0.5 * incellhd.ns_res) + continue; + if (GPJ_transform(&oproj, &iproj, &tproj, PJ_INV, &px, &py, NULL) < + 0 || + !isfinite(px) || !isfinite(py)) + continue; + poles.ox[poles.n] = px; + poles.oy[poles.n] = py; + poles.ri[poles.n] = (incellhd.north - polelat[p]) / incellhd.ns_res; + poles.n++; + } + } + G_important_message(_("Projecting (banded, per-thread PROJ context)...")); int obr0 = 0; @@ -1020,7 +1091,7 @@ int main(int argc, char **argv) while ((gs + 1) / 2 > seed_h1) gs = (gs + 1) / 2; if (!phase1_fits(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - obr0, gs, cap_bytes, cell_size)) { + obr0, gs, cap_bytes, cell_size, &poles)) { band_orows = (gs + 1) / 2; p1_seeded = 1; } @@ -1028,7 +1099,7 @@ int main(int argc, char **argv) for (;;) { band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, obr0 + band_orows, 0, outcellhd.cols, - &imin, &imax); + &imin, &imax, &poles, NULL); int strip_rows = imax - imin + 1; size_t strip_bytes = strip_rows > 0 ? (size_t)strip_rows * incellhd.cols * cell_size @@ -1069,7 +1140,7 @@ int main(int argc, char **argv) gs = (gs + 1) / 2; if (!phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, gs, cap_bytes, cell_size, - &w)) { + &w, &poles)) { start_h = (gs + 1) / 2; seed_hits++; } @@ -1078,15 +1149,15 @@ int main(int argc, char **argv) for (;;) { if (phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, band_orows, cap_bytes, - cell_size, &tilew)) + cell_size, &tilew, &poles)) break; if (band_orows == 1) { /* Single output row at minimum width still over cap = * singular/large-halo; needs the tile-cache path. */ size_t out1 = (size_t)outcellhd.cols * cell_size; - int worst = worst_tile_strip_rows(&outcellhd, &incellhd, - &oproj, &iproj, &tproj, - obr0, obr0 + 1, 1); + int worst = worst_tile_strip_rows( + &outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, + obr0 + 1, 1, &poles); size_t strip_bytes = worst > 0 ? (size_t)worst * incellhd.cols * cell_size : 0; @@ -1128,8 +1199,16 @@ int main(int argc, char **argv) /* Per-tile input row span (full-width strip: the raster API reads * whole rows, so columns are not cropped). */ + int pole_widened = 0; + band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - obr0, obr1, obc0, obc1, &imin, &imax); + obr0, obr1, obc0, obc1, &imin, &imax, &poles, + &pole_widened); + if (pole_widened) + G_verbose_message( + _("Pole (input row %d) in output tile rows [%d, %d) cols " + "[%d, %d): input strip extended to reach it"), + (int)poles.ri[pole_widened - 1], obr0, obr1, obc0, obc1); int strip_rows = imax - imin + 1; /* Serial strip load (single fd -> get_row not thread-safe). EMPTY From 1f1f2bdc3f2a9f843133afd1fe231855a271980c Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Fri, 17 Jul 2026 21:41:12 -0700 Subject: [PATCH 12/32] r.proj: add strip-based non-nearest interpolation to the banded path 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. --- raster/r.proj/interp_strip.c | 257 +++++++++++++++++++++++++++++++++++ raster/r.proj/main.c | 14 +- raster/r.proj/r.proj.h | 20 +++ 3 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 raster/r.proj/interp_strip.c diff --git a/raster/r.proj/interp_strip.c b/raster/r.proj/interp_strip.c new file mode 100644 index 00000000000..c1a18899286 --- /dev/null +++ b/raster/r.proj/interp_strip.c @@ -0,0 +1,257 @@ +/* + * interp_strip.c - strip-based interpolation kernels for the banded r.proj + * compute path. These mirror the cache-based kernels (bilinear.c, cubic.c, + * lanczos.c and their _f variants) but read an in-RAM FCELL band strip + * holding input rows [imin, imax] instead of the readcell block cache. + * Nearest is handled by interpolate_strip() in main.c and is not duplicated. + */ + +#include +#include +#include +#include +#include "r.proj.h" + +/* Read one FCELL from the band strip. The strip holds full-width input rows + * [imin, imax] contiguously; input row r maps to strip row (r - imin), the same + * addressing as interpolate_strip(). Every read is guarded by the same + * under-size tripwire as interpolate_strip: a stencil row inside the input map + * but outside the loaded strip means a sizing/indexing bug, so fail loudly + * rather than read out of bounds. Each kernel runs its full-map bounds check + * first (setting NULL for out-of-map stencils), so this tripwire only ever + * fires on a bug. */ +static inline FCELL strip_val(const void *strip, int r, int c, int imin, + int imax, int cols) +{ + if (r < imin || r > imax) + G_fatal_error(_("Band strip under-sized: input row %d outside loaded " + "range [%d, %d] at column %d"), + r, imin, imax, c); + return ((const FCELL *)strip)[(size_t)(r - imin) * cols + c]; +} + +void strip_bilinear(void *strip, void *obufptr, int cell_type, double col_idx, + double row_idx, struct Cell_head *incellhd, int imin, + int imax) +{ + int row, col, i, j; + FCELL t, u, result; + FCELL c[2][2]; + + row = (int)floor(row_idx - 0.5); + col = (int)floor(col_idx - 0.5); + + /* Full-map bounds check runs before any strip read: an out-of-map stencil + * sets NULL and returns, so strip_val is never reached out of range. */ + if (row < 0 || row + 1 >= incellhd->rows || col < 0 || + col + 1 >= incellhd->cols) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + for (i = 0; i < 2; i++) + for (j = 0; j < 2; j++) { + const FCELL cell = + strip_val(strip, row + i, col + j, imin, imax, incellhd->cols); + + if (Rast_is_f_null_value(&cell)) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + c[i][j] = cell; + } + + t = col_idx - 0.5 - col; + u = row_idx - 0.5 - row; + + result = Rast_interp_bilinear(t, u, c[0][0], c[0][1], c[1][0], c[1][1]); + + Rast_set_f_value(obufptr, result, cell_type); +} + +void strip_cubic(void *strip, void *obufptr, int cell_type, double col_idx, + double row_idx, struct Cell_head *incellhd, int imin, int imax) +{ + int row, col, i, j; + FCELL t, u, result; + FCELL val[4]; + FCELL c[4][4]; + + row = (int)floor(row_idx - 0.5); + col = (int)floor(col_idx - 0.5); + + /* Full-map bounds check runs before any strip read. */ + if (row - 1 < 0 || row + 2 >= incellhd->rows || col - 1 < 0 || + col + 2 >= incellhd->cols) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + for (i = 0; i < 4; i++) + for (j = 0; j < 4; j++) { + const FCELL cell = strip_val(strip, row - 1 + i, col - 1 + j, imin, + imax, incellhd->cols); + + if (Rast_is_f_null_value(&cell)) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + c[i][j] = cell; + } + + t = col_idx - 0.5 - col; + u = row_idx - 0.5 - row; + + for (i = 0; i < 4; i++) { + const FCELL *tmp = c[i]; + + val[i] = Rast_interp_cubic(t, tmp[0], tmp[1], tmp[2], tmp[3]); + } + + result = Rast_interp_cubic(u, val[0], val[1], val[2], val[3]); + + Rast_set_f_value(obufptr, result, cell_type); +} + +void strip_lanczos(void *strip, void *obufptr, int cell_type, double col_idx, + double row_idx, struct Cell_head *incellhd, int imin, + int imax) +{ + int row, col, i, j, k; + double t, u; + FCELL result; + DCELL c[25]; + + row = (int)floor(row_idx); + col = (int)floor(col_idx); + + /* Full-map bounds check runs before any strip read. */ + if (row - 2 < 0 || row + 2 >= incellhd->rows || col - 2 < 0 || + col + 2 >= incellhd->cols) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + k = 0; + for (i = 0; i < 5; i++) { + for (j = 0; j < 5; j++) { + const FCELL cell = strip_val(strip, row - 2 + i, col - 2 + j, imin, + imax, incellhd->cols); + + if (Rast_is_f_null_value(&cell)) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + c[k++] = cell; + } + } + + t = col_idx - 0.5 - col; + u = row_idx - 0.5 - row; + + result = Rast_interp_lanczos(t, u, c); + + Rast_set_f_value(obufptr, result, cell_type); +} + +void strip_bilinear_f(void *strip, void *obufptr, int cell_type, double col_idx, + double row_idx, struct Cell_head *incellhd, int imin, + int imax) +{ + int row, col; + FCELL cell; + + row = (int)floor(row_idx); + col = (int)floor(col_idx); + + if (row < 0 || row >= incellhd->rows || col < 0 || col >= incellhd->cols) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + cell = strip_val(strip, row, col, imin, imax, incellhd->cols); + /* if nearest is null, all the other interps will be null */ + if (Rast_is_f_null_value(&cell)) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + strip_bilinear(strip, obufptr, cell_type, col_idx, row_idx, incellhd, imin, + imax); + /* fallback to nearest if bilinear is null */ + if (Rast_is_f_null_value(obufptr)) + Rast_set_f_value(obufptr, cell, cell_type); +} + +void strip_cubic_f(void *strip, void *obufptr, int cell_type, double col_idx, + double row_idx, struct Cell_head *incellhd, int imin, + int imax) +{ + int row, col; + FCELL cell; + + row = (int)floor(row_idx); + col = (int)floor(col_idx); + + if (row < 0 || row >= incellhd->rows || col < 0 || col >= incellhd->cols) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + cell = strip_val(strip, row, col, imin, imax, incellhd->cols); + /* if nearest is null, all the other interps will be null */ + if (Rast_is_f_null_value(&cell)) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + strip_cubic(strip, obufptr, cell_type, col_idx, row_idx, incellhd, imin, + imax); + /* fallback to bilinear if cubic is null */ + if (Rast_is_f_null_value(obufptr)) { + strip_bilinear(strip, obufptr, cell_type, col_idx, row_idx, incellhd, + imin, imax); + /* fallback to nearest if bilinear is null */ + if (Rast_is_f_null_value(obufptr)) + Rast_set_f_value(obufptr, cell, cell_type); + } +} + +void strip_lanczos_f(void *strip, void *obufptr, int cell_type, double col_idx, + double row_idx, struct Cell_head *incellhd, int imin, + int imax) +{ + int row, col; + FCELL cell; + + row = (int)floor(row_idx); + col = (int)floor(col_idx); + + if (row < 0 || row >= incellhd->rows || col < 0 || col >= incellhd->cols) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + cell = strip_val(strip, row, col, imin, imax, incellhd->cols); + /* if nearest is null, all the other interps will be null */ + if (Rast_is_f_null_value(&cell)) { + Rast_set_null_value(obufptr, 1, cell_type); + return; + } + + strip_lanczos(strip, obufptr, cell_type, col_idx, row_idx, incellhd, imin, + imax); + /* fallback to bicubic if lanczos is null */ + if (Rast_is_f_null_value(obufptr)) { + strip_cubic(strip, obufptr, cell_type, col_idx, row_idx, incellhd, imin, + imax); + /* fallback to bilinear if cubic is null */ + if (Rast_is_f_null_value(obufptr)) { + strip_bilinear(strip, obufptr, cell_type, col_idx, row_idx, + incellhd, imin, imax); + /* fallback to nearest if bilinear is null */ + if (Rast_is_f_null_value(obufptr)) + Rast_set_f_value(obufptr, cell, cell_type); + } + } +} diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 9d59f4e9f56..99d76e9e695 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -130,6 +130,13 @@ static void interpolate_strip(void *strip, void *obufptr, int cell_type, memcpy(obufptr, src, cell_size); } +/* Strip-based kernels for the banded compute path, in the same order as menu[]: + * slot i is the strip counterpart of menu[i].method. Slot 0 is nearest + * (interpolate_strip above); slots 1-6 are the interp_strip.c kernels. */ +static const strip_func strip_kernels[] = { + interpolate_strip, strip_bilinear, strip_cubic, strip_lanczos, + strip_bilinear_f, strip_cubic_f, strip_lanczos_f}; + /* Geographic poles within the input map's latitude coverage. * band_input_row_span samples only the tile perimeter, so a tile whose interior * holds a pole has an input-row (latitude) extremum the perimeter misses; the @@ -571,6 +578,9 @@ int main(int argc, char **argv) G_fatal_error(_("<%s=%s> unknown %s"), interpol->key, interpol->answer, interpol->key); + /* Resolve the strip kernel once; menu[] and strip_kernels[] share order. */ + strip_func interp = strip_kernels[method]; + mapname = outmap->answer ? outmap->answer : inmap->answer; if (mapname && !list->answer && !overwrite && !print_bounds->answer && outputFormat != SHELL && G_find_raster(mapname, G_mapset())) @@ -1286,8 +1296,8 @@ int main(int argc, char **argv) (x1 - incellhd.west) / incellhd.ew_res; double r_idx = (incellhd.north - y1) / incellhd.ns_res; - interpolate_strip(strip, obufptr, cell_type, c_idx, - r_idx, &incellhd, imin, imax); + interp(strip, obufptr, cell_type, c_idx, r_idx, + &incellhd, imin, imax); } } } diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index 935415c1e41..28e46a503bb 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -27,6 +27,12 @@ struct cache { typedef void (*func)(struct cache *, void *, int, double, double, struct Cell_head *); +/* Strip-based interpolation kernels (interp_strip.c) for the banded compute + * path read an in-RAM FCELL strip holding input rows [imin, imax] instead of + * the readcell block cache, so they take imin/imax in place of struct cache. */ +typedef void (*strip_func)(void *, void *, int, double, double, + struct Cell_head *, int, int); + struct menu { func method; /* routine to interpolate new value */ char *name; /* method name */ @@ -67,6 +73,20 @@ extern void p_lanczos(struct cache *, void *, int, double, double, extern void p_lanczos_f(struct cache *, void *, int, double, double, struct Cell_head *); +/* interp_strip.c - strip variants for the banded compute path */ +extern void strip_bilinear(void *, void *, int, double, double, + struct Cell_head *, int, int); +extern void strip_cubic(void *, void *, int, double, double, struct Cell_head *, + int, int); +extern void strip_lanczos(void *, void *, int, double, double, + struct Cell_head *, int, int); +extern void strip_bilinear_f(void *, void *, int, double, double, + struct Cell_head *, int, int); +extern void strip_cubic_f(void *, void *, int, double, double, + struct Cell_head *, int, int); +extern void strip_lanczos_f(void *, void *, int, double, double, + struct Cell_head *, int, int); + #if 1 #define BKIDX(c, y, x) ((y) * (c)->stride + (x)) From f173a45d298092af7c4cce3b0518057a3af5da64 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Fri, 17 Jul 2026 22:13:46 -0700 Subject: [PATCH 13/32] r.proj: fix output row northing mismatch with serial 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. --- raster/r.proj/main.c | 135 ++++++++++++++++++++++++++----------------- 1 file changed, 82 insertions(+), 53 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 99d76e9e695..0338206221d 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -161,13 +161,12 @@ struct pole_set { * the case obc0=0, obc1=cols. Called serially, before the parallel region, so * the shared tproj is safe here. Returns imax < imin for a tile that projects * entirely outside the input. */ -static void band_input_row_span(const struct Cell_head *ohd, - const struct Cell_head *ihd, - const struct pj_info *oproj, - const struct pj_info *iproj, - const struct pj_info *tproj, int obr0, int obr1, - int obc0, int obc1, int *imin, int *imax, - const struct pole_set *poles, int *pole_widened) +static void +band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, const double *y_center, + int obr0, int obr1, int obc0, int obc1, int *imin, + int *imax, const struct pole_set *poles, int *pole_widened) { double rmin = 1e300, rmax = -1e300; int e, r, c; @@ -175,7 +174,7 @@ static void band_input_row_span(const struct Cell_head *ohd, /* top edge (row obr0) and bottom edge (row obr1-1), tile columns */ for (e = 0; e < 2; e++) { int orow = (e == 0) ? obr0 : (obr1 - 1); - double y = ohd->north - (orow + 0.5) * ohd->ns_res; + double y = y_center[orow]; for (c = obc0; c < obc1; c++) { double x = ohd->west + (c + 0.5) * ohd->ew_res; double xx = x, yy = y; @@ -193,7 +192,7 @@ static void band_input_row_span(const struct Cell_head *ohd, int ocol = (e == 0) ? obc0 : (obc1 - 1); double x = ohd->west + (ocol + 0.5) * ohd->ew_res; for (r = obr0; r < obr1; r++) { - double y = ohd->north - (r + 0.5) * ohd->ns_res; + double y = y_center[r]; double xx = x, yy = y; if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &xx, &yy, NULL) < 0) continue; @@ -254,11 +253,13 @@ static void band_input_row_span(const struct Cell_head *ohd, * call is a full tile edge-walk, so this is O(tiles * perimeter) -- paid in the * serial size phase, and only when column splitting is actually entered. * Returns 0 if every tile projects entirely outside the input. */ -static int -worst_tile_strip_rows(const struct Cell_head *ohd, const struct Cell_head *ihd, - const struct pj_info *oproj, const struct pj_info *iproj, - const struct pj_info *tproj, int obr0, int obr1, - int tilew, const struct pole_set *poles) +static int worst_tile_strip_rows(const struct Cell_head *ohd, + const struct Cell_head *ihd, + const struct pj_info *oproj, + const struct pj_info *iproj, + const struct pj_info *tproj, + const double *y_center, int obr0, int obr1, + int tilew, const struct pole_set *poles) { int worst = 0, obc0; @@ -268,8 +269,8 @@ worst_tile_strip_rows(const struct Cell_head *ohd, const struct Cell_head *ihd, if (obc1 > ohd->cols) obc1 = ohd->cols; - band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr1, obc0, - obc1, &imin, &imax, poles, NULL); + band_input_row_span(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr1, + obc0, obc1, &imin, &imax, poles, NULL); rows = imax - imin + 1; /* imax < imin (empty) -> <= 0, ignored */ if (rows > worst) worst = rows; @@ -284,13 +285,11 @@ worst_tile_strip_rows(const struct Cell_head *ohd, const struct Cell_head *ihd, * including the first and last. A subset max is a LOWER bound on the true * worst, so it only PRUNES the Phase-2 search; the chosen width is exact- * validated by worst_tile_strip_rows before use. */ -static int est_worst_tile_strip_rows(const struct Cell_head *ohd, - const struct Cell_head *ihd, - const struct pj_info *oproj, - const struct pj_info *iproj, - const struct pj_info *tproj, int obr0, - int obr1, int tilew, int probe, - const struct pole_set *poles) +static int est_worst_tile_strip_rows( + const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, const double *y_center, int obr0, int obr1, + int tilew, int probe, const struct pole_set *poles) { int ntiles = (ohd->cols + tilew - 1) / tilew; int worst = 0, k; @@ -307,8 +306,8 @@ static int est_worst_tile_strip_rows(const struct Cell_head *ohd, if (obc1 > ohd->cols) obc1 = ohd->cols; - band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr1, obc0, - obc1, &imin, &imax, poles, NULL); + band_input_row_span(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr1, + obc0, obc1, &imin, &imax, poles, NULL); rows = imax - imin + 1; if (rows > worst) worst = rows; @@ -321,11 +320,13 @@ static int est_worst_tile_strip_rows(const struct Cell_head *ohd, * width whose worst input strip fits (setting *acc_tilew to that width, via the * same upper-tier estimate then lower-tier exact validation the search uses); * 0 if no width fits or the output buffer alone exceeds the cap. */ -static int -phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, - const struct pj_info *oproj, const struct pj_info *iproj, - const struct pj_info *tproj, int obr0, int h, size_t cap_bytes, - int cell_size, int *acc_tilew, const struct pole_set *poles) +static int phase2_width_fit(const struct Cell_head *ohd, + const struct Cell_head *ihd, + const struct pj_info *oproj, + const struct pj_info *iproj, + const struct pj_info *tproj, const double *y_center, + int obr0, int h, size_t cap_bytes, int cell_size, + int *acc_tilew, const struct pole_set *poles) { size_t out_bytes = (size_t)h * ohd->cols * cell_size; int tilew, est_fit; @@ -335,8 +336,9 @@ phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, tilew = ohd->cols; est_fit = 0; for (;;) { - int est = est_worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, obr0, - obr0 + h, tilew, TILE_PROBE, poles); + int est = + est_worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, y_center, + obr0, obr0 + h, tilew, TILE_PROBE, poles); size_t est_bytes = est > 0 ? (size_t)est * ihd->cols * cell_size : 0; if (est_bytes + out_bytes <= cap_bytes) { est_fit = 1; @@ -348,8 +350,9 @@ phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, } if (est_fit) { for (;;) { - int worst = worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, - obr0, obr0 + h, tilew, poles); + int worst = + worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, y_center, + obr0, obr0 + h, tilew, poles); size_t strip_bytes = worst > 0 ? (size_t)worst * ihd->cols * cell_size : 0; if (strip_bytes + out_bytes <= cap_bytes) { @@ -371,8 +374,8 @@ phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, * so the miss path is byte-for-byte today's execution. */ static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, - const struct pj_info *tproj, int obr0, int h, - size_t cap_bytes, int cell_size, + const struct pj_info *tproj, const double *y_center, + int obr0, int h, size_t cap_bytes, int cell_size, const struct pole_set *poles) { int imin, imax, strip_rows; @@ -380,8 +383,8 @@ static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, if (out_bytes > cap_bytes) return 0; - band_input_row_span(ohd, ihd, oproj, iproj, tproj, obr0, obr0 + h, 0, - ohd->cols, &imin, &imax, poles, NULL); + band_input_row_span(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr0 + h, + 0, ohd->cols, &imin, &imax, poles, NULL); strip_rows = imax - imin + 1; strip_bytes = strip_rows > 0 ? (size_t)strip_rows * ihd->cols * cell_size : 0; @@ -1039,6 +1042,29 @@ int main(int argc, char **argv) int seed_h1 = 0; /* previous Phase-1 band's accepted height */ int p1_hits = 0, p1_bands = 0; /* seed hit rate on the Phase-1 path */ + /* Output-row center northings, precomputed once by the serial version's + * recurrence: ycoord2 = north - ns_res/2, then ycoord2 -= ns_res per row. + * The banded fill loop and the strip-sizing perimeter walk both read these + * instead of computing north - ns_res/2 - row*ns_res directly. The direct + * multiply and the accumulated subtraction differ by up to one ULP when + * ns_res is not exactly representable; for non-nearest interpolation that + * shifts the sampling weights and diverges from the serial result by up to + * one FCELL ULP. The recurrence is reproduced here deliberately + * (bug-compatible rounding) so the parallel output stays bitwise identical + * to the serial reference; the direct multiply is the numerically cleaner + * form, so any future change away from the recurrence should be made in + * both code paths as an explicit accuracy decision. Both the fill loop and + * the sizing walk read these values, so sizing and fill stay on the same y + * and the loaded strip covers exactly the rows fill probes. */ + double *y_center = G_malloc((size_t)outcellhd.rows * sizeof(double)); + { + double yc = outcellhd.north - (outcellhd.ns_res / 2); + for (int r = 0; r < outcellhd.rows; r++) { + y_center[r] = yc; + yc -= outcellhd.ns_res; + } + } + /* Pole footprint fix: a tile whose interior holds a geographic pole has an * input-row extremum the perimeter walk misses. Precompute each in-range * pole's output coordinate and input row (lat/lon input only, where a pole @@ -1101,15 +1127,16 @@ int main(int argc, char **argv) while ((gs + 1) / 2 > seed_h1) gs = (gs + 1) / 2; if (!phase1_fits(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - obr0, gs, cap_bytes, cell_size, &poles)) { + y_center, obr0, gs, cap_bytes, cell_size, + &poles)) { band_orows = (gs + 1) / 2; p1_seeded = 1; } } for (;;) { band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - obr0, obr0 + band_orows, 0, outcellhd.cols, - &imin, &imax, &poles, NULL); + y_center, obr0, obr0 + band_orows, 0, + outcellhd.cols, &imin, &imax, &poles, NULL); int strip_rows = imax - imin + 1; size_t strip_bytes = strip_rows > 0 ? (size_t)strip_rows * incellhd.cols * cell_size @@ -1149,8 +1176,8 @@ int main(int argc, char **argv) while ((gs + 1) / 2 > seed_h) gs = (gs + 1) / 2; if (!phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, - &tproj, obr0, gs, cap_bytes, cell_size, - &w, &poles)) { + &tproj, y_center, obr0, gs, cap_bytes, + cell_size, &w, &poles)) { start_h = (gs + 1) / 2; seed_hits++; } @@ -1158,16 +1185,16 @@ int main(int argc, char **argv) band_orows = start_h; for (;;) { if (phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, - &tproj, obr0, band_orows, cap_bytes, - cell_size, &tilew, &poles)) + &tproj, y_center, obr0, band_orows, + cap_bytes, cell_size, &tilew, &poles)) break; if (band_orows == 1) { /* Single output row at minimum width still over cap = * singular/large-halo; needs the tile-cache path. */ size_t out1 = (size_t)outcellhd.cols * cell_size; int worst = worst_tile_strip_rows( - &outcellhd, &incellhd, &oproj, &iproj, &tproj, obr0, - obr0 + 1, 1, &poles); + &outcellhd, &incellhd, &oproj, &iproj, &tproj, y_center, + obr0, obr0 + 1, 1, &poles); size_t strip_bytes = worst > 0 ? (size_t)worst * incellhd.cols * cell_size : 0; @@ -1212,8 +1239,8 @@ int main(int argc, char **argv) int pole_widened = 0; band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - obr0, obr1, obc0, obc1, &imin, &imax, &poles, - &pole_widened); + y_center, obr0, obr1, obc0, obc1, &imin, &imax, + &poles, &pole_widened); if (pole_widened) G_verbose_message( _("Pole (input row %d) in output tile rows [%d, %d) cols " @@ -1276,8 +1303,7 @@ int main(int argc, char **argv) void *out_row = (unsigned char *)band_out + (size_t)(row - obr0) * outcellhd.cols * cell_size; - double local_y = outcellhd.north - (outcellhd.ns_res / 2) - - (row * outcellhd.ns_res); + double local_y = y_center[row]; double local_x_start = outcellhd.west + (outcellhd.ew_res / 2); @@ -1326,9 +1352,12 @@ int main(int argc, char **argv) obr0 = obr1; } + G_free(y_center); + G_debug(1, - "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f bands=%d " - "tiles=%d seed_hits=%d phase2_bands=%d p1_hits=%d p1_bands=%d", + "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f " + "bands=%d tiles=%d seed_hits=%d phase2_bands=%d p1_hits=%d " + "p1_bands=%d", t_size, t_fill, t_compute, t_write, n_bands, max_tiles, seed_hits, phase2_bands, p1_hits, p1_bands); From d93c2754a3b742b4579ec975cd67e19f22f6bdfe Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Fri, 17 Jul 2026 22:19:17 -0700 Subject: [PATCH 14/32] r.proj: fall back to serial tile cache instead of aborting 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. --- raster/r.proj/main.c | 163 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 23 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 0338206221d..66de006ce80 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -391,6 +391,60 @@ static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, return strip_bytes + out_bytes <= cap_bytes; } +/* Serial tile-cache fallback for the large-halo/oblique corner: when even a + * single output row's full-width input strip busts the memory cap (the bail in + * the band loop), the banded strip path cannot proceed. This finishes the run + * from output row obr0 onward using the classic readcell block cache (faults + * blocks on demand, bounded by the same memory option via nblocks) and the + * CVAL cache kernels (menu[].method), exactly as the serial r.proj does. + * + * Runs strictly serially: get_block mutates shared cache state and is not + * thread-safe. Rows [0, obr0) were already written by the banded path; each + * output row is independent of the others, so the banded prefix followed by + * this serial suffix is bit-identical to a pure serial run. y_center supplies + * the same output-row northings the banded prefix used (and that serial's + * ycoord2 recurrence produces), so the seam at obr0 is seamless. A transform + * failure sets NULL here (matching the banded strip path) rather than the old + * serial fatal; identical on data where transforms succeed. */ +static void +fallback_serial_cache(int fdi, int fdo, int cell_type, int method, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, struct Cell_head *incellhd, + struct Cell_head *outcellhd, const double *y_center, + int obr0, const char *memory) +{ + struct cache *ibuffer = readcell(fdi, memory); + func interpolate = menu[method].method; + void *obuffer = Rast_allocate_output_buf(cell_type); + int cell_size = Rast_cell_size(cell_type); + double local_x_start = outcellhd->west + (outcellhd->ew_res / 2); + + for (int row = obr0; row < outcellhd->rows; row++) { + G_percent(row - obr0, outcellhd->rows - obr0, 5); + for (int col = 0; col < outcellhd->cols; col++) { + void *obufptr = (unsigned char *)obuffer + (size_t)col * cell_size; + double x1 = local_x_start + col * outcellhd->ew_res; + double y1 = y_center[row]; + + if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &x1, &y1, NULL) < + 0) { + Rast_set_null_value(obufptr, 1, cell_type); + } + else { + double col_idx = (x1 - incellhd->west) / incellhd->ew_res; + double row_idx = (incellhd->north - y1) / incellhd->ns_res; + + interpolate(ibuffer, obufptr, cell_type, col_idx, row_idx, + incellhd); + } + } + Rast_put_row(fdo, obuffer, cell_type); + } + + release_cache(ibuffer); + G_free(obuffer); +} + int main(int argc, char **argv) { char *mapname, /* ptr to name of output layer */ @@ -1096,6 +1150,8 @@ int main(int argc, char **argv) G_important_message(_("Projecting (banded, per-thread PROJ context)...")); + int used_fallback = 0; /* set when the serial tile-cache fallback runs */ + int force_tilecache = getenv("R_PROJ_FORCE_TILECACHE") != NULL; int obr0 = 0; while (obr0 < outcellhd.rows) { /* Fit search. Phase 1 (fast path, unchanged): halve the band height @@ -1107,6 +1163,39 @@ int main(int argc, char **argv) * rows), so width splitting shrinks a tile's input ROW span, not its * width. Easy pairs never leave Phase 1. */ double ts = rproj_wtime(); + /* Band-0 early-out for the wide-input corner: if a single output row at + * the finest tiling already busts the cap, take the serial fallback now + * instead of running the height/width search only to bail. Uses the + * same worst_tile_strip_rows(obr0, obr0+1, 1) the Phase-2 bail uses, + * probed only at the first band so its O(cols) cost is paid once, not + * per band. Later-band (pole) busts still fall through to the Phase-2 + * bail. force_tilecache is deliberately not handled here, so the forced + * override keeps routing through that bail unchanged. */ + if (obr0 == 0) { + size_t out1 = (size_t)outcellhd.cols * cell_size; + int worst1 = worst_tile_strip_rows(&outcellhd, &incellhd, &oproj, + &iproj, &tproj, y_center, obr0, + obr0 + 1, 1, &poles); + size_t strip1 = + worst1 > 0 ? (size_t)worst1 * incellhd.cols * cell_size : 0; + if (strip1 + out1 > cap_bytes) { + int needed_mb = + (int)ceil((double)(strip1 + out1) / (1024.0 * 1024.0)) + 1; + G_warning(_("Memory cap (%.1f MB) is below what one output row " + "needs (input footprint %d rows, %.1f MB). Falling " + "back to the serial tile-cache path for output " + "rows %d-%d; this path is slower. Raise memory= to " + "at least %d MB to use the parallel path."), + cap_mb, worst1, + (double)(strip1 + out1) / (1024.0 * 1024.0), obr0, + outcellhd.rows - 1, needed_mb); + fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, + &iproj, &tproj, &incellhd, &outcellhd, + y_center, obr0, memory->answer); + used_fallback = 1; + goto fallback_done; + } + } int tilew = outcellhd.cols; int imin = 0, imax = -1; /* Phase-1 neighbor seed (hit path): seed_h1 (previous Phase-1 accepted @@ -1142,7 +1231,7 @@ int main(int argc, char **argv) strip_rows > 0 ? (size_t)strip_rows * incellhd.cols * cell_size : 0; size_t out_bytes = (size_t)band_orows * outcellhd.cols * cell_size; - if (strip_bytes + out_bytes <= cap_bytes) + if (!force_tilecache && strip_bytes + out_bytes <= cap_bytes) break; if (band_orows == 1) break; /* height exhausted: fall through to column splitting */ @@ -1184,27 +1273,51 @@ int main(int argc, char **argv) } band_orows = start_h; for (;;) { - if (phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, + if (!force_tilecache && + phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, &tproj, y_center, obr0, band_orows, cap_bytes, cell_size, &tilew, &poles)) break; if (band_orows == 1) { /* Single output row at minimum width still over cap = - * singular/large-halo; needs the tile-cache path. */ - size_t out1 = (size_t)outcellhd.cols * cell_size; - int worst = worst_tile_strip_rows( - &outcellhd, &incellhd, &oproj, &iproj, &tproj, y_center, - obr0, obr0 + 1, 1, &poles); - size_t strip_bytes = - worst > 0 ? (size_t)worst * incellhd.cols * cell_size - : 0; - G_fatal_error( - _("A single output row needs %.1f MB (input footprint " - "%d rows), exceeding the memory cap (%.1f MB). This " - "large-halo/oblique case needs the tile-cache path, " - "which is not implemented."), - (double)(strip_bytes + out1) / (1024.0 * 1024.0), worst, - cap_mb); + * singular/large-halo; take the serial tile-cache path. + * Also reached from band 0 when R_PROJ_FORCE_TILECACHE is + * set, which routes normal data through this identical + * block for testing. */ + if (force_tilecache) { + G_warning( + _("R_PROJ_FORCE_TILECACHE is set: taking the " + "serial tile-cache path for all output rows " + "(testing override).")); + } + else { + size_t out1 = (size_t)outcellhd.cols * cell_size; + int worst = worst_tile_strip_rows( + &outcellhd, &incellhd, &oproj, &iproj, &tproj, + y_center, obr0, obr0 + 1, 1, &poles); + size_t strip_bytes = + worst > 0 + ? (size_t)worst * incellhd.cols * cell_size + : 0; + int needed_mb = (int)ceil((double)(strip_bytes + out1) / + (1024.0 * 1024.0)) + + 1; + G_warning( + _("Memory cap (%.1f MB) is below what one output " + "row needs (input footprint %d rows, %.1f MB). " + "Falling back to the serial tile-cache path for " + "output rows %d-%d; this path is slower. Raise " + "memory= to at least %d MB to use the parallel " + "path."), + cap_mb, worst, + (double)(strip_bytes + out1) / (1024.0 * 1024.0), + obr0, outcellhd.rows - 1, needed_mb); + } + fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, + &iproj, &tproj, &incellhd, &outcellhd, + y_center, obr0, memory->answer); + used_fallback = 1; + goto fallback_done; } band_orows = (band_orows + 1) / 2; } @@ -1352,14 +1465,18 @@ int main(int argc, char **argv) obr0 = obr1; } +fallback_done: G_free(y_center); - G_debug(1, - "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f " - "bands=%d tiles=%d seed_hits=%d phase2_bands=%d p1_hits=%d " - "p1_bands=%d", - t_size, t_fill, t_compute, t_write, n_bands, max_tiles, seed_hits, - phase2_bands, p1_hits, p1_bands); + if (used_fallback) + G_debug(1, "PHASE_TIMERS fallback=1 fallback_from_row=%d", obr0); + else + G_debug(1, + "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f " + "bands=%d tiles=%d seed_hits=%d phase2_bands=%d p1_hits=%d " + "p1_bands=%d", + t_size, t_fill, t_compute, t_write, n_bands, max_tiles, + seed_hits, phase2_bands, p1_hits, p1_bands); /* Close input map in its own env, then the output map. */ G_switch_env(); /* -> input */ From 77ab0ce989a73ba5239c39f7cc547f0c381c0d2e Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Mon, 20 Jul 2026 20:56:19 -0700 Subject: [PATCH 15/32] lib/proj: add NULL checks to GPJ_clone_transform 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. --- lib/proj/do_proj.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/proj/do_proj.c b/lib/proj/do_proj.c index 66be1c7e5ed..795c6a92f7d 100644 --- a/lib/proj/do_proj.c +++ b/lib/proj/do_proj.c @@ -1433,8 +1433,17 @@ void GPJ_clone_transform(const struct pj_info *src, struct gpj_transform_clone *clone) { clone->ctx = proj_context_create(); + /* r.proj calls this in each worker thread, so a fatal here ends the whole + * process from inside the parallel region. That is intended: a clone + * failure leaves the thread with no usable transform. */ + if (clone->ctx == NULL) + G_fatal_error(_("proj_context_create() failed for a per-thread " + "transform clone")); clone->info = *src; clone->info.pj = proj_clone(clone->ctx, src->pj); + if (clone->info.pj == NULL) + G_fatal_error(_("proj_clone() failed for a per-thread transform " + "clone")); } /*! From cea36e12c41e4983541e2ca2209604513bbd3fb2 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Mon, 20 Jul 2026 21:00:00 -0700 Subject: [PATCH 16/32] r.proj: fix band sizing for pole-centered frames reading a truncated 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. --- raster/r.proj/main.c | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 66de006ce80..f1329ba228f 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -1119,12 +1119,20 @@ int main(int argc, char **argv) } } - /* Pole footprint fix: a tile whose interior holds a geographic pole has an - * input-row extremum the perimeter walk misses. Precompute each in-range - * pole's output coordinate and input row (lat/lon input only, where a pole - * is at latitude +/- 90). On transform failure or a non-finite result the - * pole is skipped and the strip under-size guard stays the backstop. Uses - * the adjusted incellhd, matching what band_input_row_span sees. */ + /* Pole footprint fix: a tile whose interior projects onto a geographic pole + * has an input-row extremum the perimeter walk misses. This happens both + * when the pole lies inside the input map and when the pole is outside the + * input's latitude coverage but its projection still falls inside the + * output frame (a pole-centered frame reading an input truncated below the + * pole): the highest reachable input latitude is then the input's own edge + * row, reached at the frame-center-proximal interior. So project both poles + * (lat/lon input only, where a pole is at latitude +/- 90) and fold in the + * pole's input row clamped to the input's edge row [0, rows-1]. The + * point-in-rect test in band_input_row_span keeps this a no-op for frames + * that do not image a pole. On transform failure or a non-finite result + * (e.g. a cylindrical projection sending the pole to infinity) the pole is + * skipped and the strip under-size guard stays the backstop. Uses the + * adjusted incellhd, matching what band_input_row_span sees. */ struct pole_set poles; poles.n = 0; @@ -1134,16 +1142,18 @@ int main(int argc, char **argv) for (int p = 0; p < 2; p++) { double px = 0.0, py = polelat[p]; - if (polelat[p] > incellhd.north + 0.5 * incellhd.ns_res || - polelat[p] < incellhd.south - 0.5 * incellhd.ns_res) - continue; if (GPJ_transform(&oproj, &iproj, &tproj, PJ_INV, &px, &py, NULL) < 0 || !isfinite(px) || !isfinite(py)) continue; + double ri = (incellhd.north - polelat[p]) / incellhd.ns_res; + if (ri < 0) + ri = 0; + else if (ri > incellhd.rows - 1) + ri = incellhd.rows - 1; poles.ox[poles.n] = px; poles.oy[poles.n] = py; - poles.ri[poles.n] = (incellhd.north - polelat[p]) / incellhd.ns_res; + poles.ri[poles.n] = ri; poles.n++; } } From e5e7622260000f7aa71380d1868f5e142ad87026 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Mon, 20 Jul 2026 21:07:16 -0700 Subject: [PATCH 17/32] r.proj: add parallel-correctness pytest tests 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. --- raster/r.proj/tests/conftest.py | 75 +++++++++ raster/r.proj/tests/r_proj_parallel_test.py | 177 ++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 raster/r.proj/tests/conftest.py create mode 100644 raster/r.proj/tests/r_proj_parallel_test.py diff --git a/raster/r.proj/tests/conftest.py b/raster/r.proj/tests/conftest.py new file mode 100644 index 00000000000..cdd5800dc82 --- /dev/null +++ b/raster/r.proj/tests/conftest.py @@ -0,0 +1,75 @@ +"""Fixtures for the r.proj parallel-correctness pytest. + +Builds one GISDBASE holding an EPSG:4326 source project with two small +generated input rasters, plus EPSG:3857 and EPSG:3413 (north polar +stereographic) destination projects. r.proj reprojects from the source +into the active destination session; the tests compare the module's own +serial and parallel runs. + +The input is integer CELL with values well below 2^24 +(row()*100 + col() + (row()*row()+col()*col())%13, max ~5058), so it survives +a float32 round-trip losslessly. This is deliberate: the forced tile-cache +path reads through the FCELL readcell cache while the banded nearest path +reads the native type, so only a float32-exact input keeps the forced-fallback +bitwise assert valid (a DCELL input would diverge by float32 quantization +alone). The (row()*row()+col()*col())%13 term gives the surface enough +curvature that bilinear and bicubic interpolation diverge past the reference +test's rel=1e-7 tolerance (a linear ramp, or a milder term, leaves their +statistics identical or within tolerance), which the method reference test +relies on to catch an _f-kernel dispatch swap. Values depend on +grid position only (no trig, no random), so they are bit-identical across +platforms and resolutions. Both rasters are 50x50 to stay well under the CI +time budget. +""" + +import os + +import pytest + +import grass.script as gs + +INPUT_EXPRESSION = "row() * 100 + col() + (row() * row() + col() * col()) % 13" + +SRC_PROJECT = "src4326" +# Mid-latitude box for the 3857 identity/fallback cases. +INPUT_MID = "input_mid" +# High-latitude, full-longitude box so a north-polar frame has data to read. +INPUT_POLAR = "input_polar" + + +@pytest.fixture(scope="module") +def gisdbase_with_source(tmp_path_factory): + """GISDBASE containing src4326 with the mid and polar input rasters.""" + gisdbase = tmp_path_factory.mktemp("rproj_parallel") + gs.create_project(gisdbase / SRC_PROJECT, epsg="4326") + with gs.setup.init(gisdbase / SRC_PROJECT, env=os.environ.copy()) as session: + env = session.env + gs.run_command("g.region", n=41, s=40, w=-100, e=-99, rows=50, cols=50, env=env) + gs.run_command( + "r.mapcalc", expression=f"{INPUT_MID} = {INPUT_EXPRESSION}", env=env + ) + gs.run_command("g.region", n=89, s=50, w=-180, e=180, rows=50, cols=50, env=env) + gs.run_command( + "r.mapcalc", expression=f"{INPUT_POLAR} = {INPUT_EXPRESSION}", env=env + ) + return gisdbase + + +@pytest.fixture(scope="module") +def session_3857(gisdbase_with_source): + """Active session in an EPSG:3857 destination project.""" + gs.create_project(gisdbase_with_source / "dst3857", epsg="3857") + with gs.setup.init( + gisdbase_with_source / "dst3857", env=os.environ.copy() + ) as session: + yield session + + +@pytest.fixture(scope="module") +def session_pole(gisdbase_with_source): + """Active session in an EPSG:3413 (north polar stereographic) project.""" + gs.create_project(gisdbase_with_source / "dst_pole", epsg="3413") + with gs.setup.init( + gisdbase_with_source / "dst_pole", env=os.environ.copy() + ) as session: + yield session diff --git a/raster/r.proj/tests/r_proj_parallel_test.py b/raster/r.proj/tests/r_proj_parallel_test.py new file mode 100644 index 00000000000..573d53331ef --- /dev/null +++ b/raster/r.proj/tests/r_proj_parallel_test.py @@ -0,0 +1,177 @@ +"""Parallel-correctness tests for r.proj. + +r.proj has no nprocs= option; its thread count comes from OMP_NUM_THREADS. +Every run below is given its OWN environment dict, a copy of the session +env with OMP_NUM_THREADS (and, for the fallback test, R_PROJ_FORCE_TILECACHE) +set on the copy for that run only. Nothing shared is mutated, so the serial +and parallel runs of a test cannot leak thread or path state into each other. + +The baseline is the module's own single-thread run (OMP_NUM_THREADS=1), not +an external serial binary. The question these tests answer is whether adding +threads, or taking the tile-cache fallback, changes the output of this same +binary. That comparison is exact and reproducible in CI; an external oracle +would not be. + +Correctness rule: nearest is asserted bitwise (abs diff max == 0). Bilinear +is asserted bitwise too, because each output cell is interpolated +independently in a fixed operation order, so threading does not reorder its +arithmetic. The epsilon-1e-6 fallback from the proposal may be invoked only +on an actual CI reordering failure, naming the platform that showed it. +""" + +import grass.script as gs + +# Mirror of the names created in conftest.py. +SRC_PROJECT = "src4326" +INPUT_MID = "input_mid" +INPUT_POLAR = "input_polar" + + +def _env(session, **overrides): + """Session env copy with per-run overrides; never mutates the original.""" + env = dict(session.env) + for key, value in overrides.items(): + env[key] = str(value) + return env + + +def _set_region_from_source(env, input_raster, method): + """Set the output region to r.proj's suggested bounds for the input. + + r.proj -g prints the whole region as space-separated key=value pairs on + one line, so split on whitespace first, then on '='.""" + text = gs.read_command( + "r.proj", + project=SRC_PROJECT, + mapset="PERMANENT", + input=input_raster, + method=method, + flags="g", + env=env, + ) + region = dict(token.split("=") for token in text.split()) + gs.run_command( + "g.region", + n=region["n"], + s=region["s"], + e=region["e"], + w=region["w"], + rows=region["rows"], + cols=region["cols"], + env=env, + ) + + +def _project(env, input_raster, output, method, **extra): + gs.run_command( + "r.proj", + project=SRC_PROJECT, + mapset="PERMANENT", + input=input_raster, + output=output, + method=method, + overwrite=True, + quiet=True, + env=env, + **extra, + ) + + +def _stats(env, raster): + return gs.parse_command("r.univar", map=raster, flags="g", env=env) + + +def _assert_bitwise_identical(env, a, b, diff): + """Assert a and b are bitwise identical: equal counts, equal null + pattern, and a zero-valued absolute difference over a non-empty map.""" + gs.run_command( + "r.mapcalc", expression=f"{diff} = abs({a} - {b})", overwrite=True, env=env + ) + sa = _stats(env, a) + sb = _stats(env, b) + sd = _stats(env, diff) + assert int(sa["n"]) > 0, "output is empty; the comparison would be vacuous" + assert int(sa["n"]) == int(sb["n"]) + assert int(sa["null_cells"]) == int(sb["null_cells"]) + assert float(sd["max"]) == 0.0 + + +def test_bilinear_parallel_matches_serial(session_3857): + """Bilinear: parallel output must equal the serial output bitwise. + + A dispatch-liveness guard runs first: bilinear must differ from nearest + on the same frame, so a silent fallback to nearest cannot make the + identity assert pass vacuously (the Bug A regression guard).""" + session = session_3857 + base = _env(session) + _set_region_from_source(base, INPUT_MID, "bilinear") + + _project(_env(session, OMP_NUM_THREADS=1), INPUT_MID, "bilin_serial", "bilinear") + _project(_env(session, OMP_NUM_THREADS=1), INPUT_MID, "nearest_ref", "nearest") + gs.run_command( + "r.mapcalc", + expression="dispatch_live = abs(bilin_serial - nearest_ref)", + overwrite=True, + env=base, + ) + assert float(_stats(base, "dispatch_live")["max"]) > 0, ( + "bilinear output equals nearest; dispatch may have fallen back" + ) + + _project(_env(session, OMP_NUM_THREADS=4), INPUT_MID, "bilin_parallel", "bilinear") + _assert_bitwise_identical(base, "bilin_serial", "bilin_parallel", "bilin_diff") + + +def test_nearest_memory_banding(session_3857): + """Nearest with a constrained memory cap (memory=5, OMP=4) must match the + default-memory serial run bitwise, exercising band sizing at a small cap.""" + session = session_3857 + base = _env(session) + _set_region_from_source(base, INPUT_MID, "nearest") + + _project(_env(session, OMP_NUM_THREADS=1), INPUT_MID, "mem_serial", "nearest") + _project( + _env(session, OMP_NUM_THREADS=4), INPUT_MID, "mem_banded", "nearest", memory=5 + ) + _assert_bitwise_identical(base, "mem_serial", "mem_banded", "mem_diff") + + +def test_pole_nearest_parallel_matches_serial(session_pole): + """Nearest into a frame centered on the north pole: the warped access + pattern near the pole must still give bitwise-identical parallel output.""" + session = session_pole + base = _env(session) + # Fixed 1200 km box centered on the pole (EPSG:3413 meters), 50x50. + gs.run_command( + "g.region", + n=600000, + s=-600000, + e=600000, + w=-600000, + rows=50, + cols=50, + env=base, + ) + + _project(_env(session, OMP_NUM_THREADS=1), INPUT_POLAR, "pole_serial", "nearest") + _project(_env(session, OMP_NUM_THREADS=4), INPUT_POLAR, "pole_parallel", "nearest") + _assert_bitwise_identical(base, "pole_serial", "pole_parallel", "pole_diff") + + +def test_forced_fallback_matches_banded(session_3857): + """The forced serial tile-cache path must equal the banded parallel path + bitwise. R_PROJ_FORCE_TILECACHE=1 takes the readcell tile-cache route + (a different algorithm), so this is a cross-path check, not just a + thread-count one.""" + session = session_3857 + base = _env(session) + _set_region_from_source(base, INPUT_MID, "nearest") + + _project( + _env(session, OMP_NUM_THREADS=1, R_PROJ_FORCE_TILECACHE=1), + INPUT_MID, + "fallback_tilecache", + "nearest", + ) + _project(_env(session, OMP_NUM_THREADS=4), INPUT_MID, "banded", "nearest") + _assert_bitwise_identical(base, "fallback_tilecache", "banded", "fallback_diff") From 9092740481eddc16b16e49068724db34b7493a0e Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Mon, 20 Jul 2026 23:33:50 -0700 Subject: [PATCH 18/32] r.proj: migrate method tests from gunittest to pytest 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. --- raster/r.proj/tests/r_proj_methods_test.py | 145 ++++++++++ raster/r.proj/testsuite/test_rproj.py | 300 --------------------- 2 files changed, 145 insertions(+), 300 deletions(-) create mode 100644 raster/r.proj/tests/r_proj_methods_test.py delete mode 100644 raster/r.proj/testsuite/test_rproj.py diff --git a/raster/r.proj/tests/r_proj_methods_test.py b/raster/r.proj/tests/r_proj_methods_test.py new file mode 100644 index 00000000000..c88c863dafa --- /dev/null +++ b/raster/r.proj/tests/r_proj_methods_test.py @@ -0,0 +1,145 @@ +"""Reference-value test for r.proj across interpolation methods. + +For every interpolation method, r.proj is run at OMP_NUM_THREADS=1 on the +generated input_mid raster reprojected into EPSG:3857, and its univariate +statistics are compared against reference values. This verifies that changes +such as the OpenMP parallelization do not change the serial output. + +Reference provenance: values captured from r.proj.serial (md5 +cde07de7d7b2e2798fb203fc01b27714), the frozen main-branch serial binary, run +at OMP_NUM_THREADS=1 on the session_3857 / input_mid fixture data with the +output region set from r.proj -g. The single-thread run matches the +single-thread reference. All seven references are pairwise distinct, and every +pairwise method swap is catchable at the primary rel=1e-7 tolerance (the +input_mid curvature term is chosen so bilinear_f and bicubic_f diverge past +1e-7). + +The rel=1e-6 relaxation pre-registered below, if ever invoked on a CI +floating-point flutter, sacrifices the bilinear_f/bicubic_f swap detection +(their means differ by only ~2.6e-7 relative, under 1e-6) -- so relaxing is a +visible trade, not a silent one. +""" + +import pytest + +import grass.script as gs + +SRC_PROJECT = "src4326" +INPUT_MID = "input_mid" + +REFERENCE = { + "nearest": { + "n": 2500, + "null_cells": 0, + "min": 103, + "max": 5058, + "mean": 2581.0344, + "stddev": 1443.17823750798, + }, + "bilinear": { + "n": 2352, + "null_cells": 148, + "min": 202.023132324219, + "max": 5054.64990234375, + "mean": 2624.75047491683, + "stddev": 1414.50216671975, + }, + "bicubic": { + "n": 2162, + "null_cells": 338, + "min": 306.120361328125, + "max": 4958.90966796875, + "mean": 2624.59341703092, + "stddev": 1356.74263472616, + }, + "lanczos": { + "n": 2116, + "null_cells": 384, + "min": 305.625366210938, + "max": 4851.7939453125, + "mean": 2572.88704589094, + "stddev": 1327.61055457531, + }, + "bilinear_f": { + "n": 2500, + "null_cells": 0, + "min": 103, + "max": 5058, + "mean": 2575.07724680176, + "stddev": 1443.17041934389, + }, + "bicubic_f": { + "n": 2500, + "null_cells": 0, + "min": 103, + "max": 5058, + "mean": 2575.0779140625, + "stddev": 1443.17057576919, + }, + "lanczos_f": { + "n": 2500, + "null_cells": 0, + "min": 103, + "max": 5058, + "mean": 2573.76246621094, + "stddev": 1443.17228983607, + }, +} + +METHODS = list(REFERENCE) + + +def _set_region_from_source(env): + """Set the output region to r.proj's suggested bounds (method-independent).""" + text = gs.read_command( + "r.proj", + project=SRC_PROJECT, + mapset="PERMANENT", + input=INPUT_MID, + method="nearest", + flags="g", + env=env, + ) + region = dict(token.split("=") for token in text.split()) + gs.run_command( + "g.region", + n=region["n"], + s=region["s"], + e=region["e"], + w=region["w"], + rows=region["rows"], + cols=region["cols"], + env=env, + ) + + +@pytest.mark.parametrize("method", METHODS) +def test_method_matches_serial_reference(session_3857, method): + """r.proj serial output stats must match the captured serial reference.""" + env = dict(session_3857.env) + env["OMP_NUM_THREADS"] = "1" + _set_region_from_source(env) + + output = f"ref_{method}" + gs.run_command( + "r.proj", + project=SRC_PROJECT, + mapset="PERMANENT", + input=INPUT_MID, + output=output, + method=method, + overwrite=True, + quiet=True, + env=env, + ) + stats = gs.parse_command("r.univar", map=output, flags="g", env=env) + reference = REFERENCE[method] + + assert int(stats["n"]) == reference["n"] + assert int(stats["null_cells"]) == reference["null_cells"] + # Primary tolerance rel=1e-7. The only permitted relaxation, on an actual + # CI floating-point flutter (naming the platform that showed it), is Anna's + # r.param.scale tolerance rel=1e-6, abs=5e-8; see the module docstring for + # the bilinear_f/bicubic_f detection it trades away. + for field in ("min", "max", "mean", "stddev"): + assert float(stats[field]) == pytest.approx(reference[field], rel=1e-7) diff --git a/raster/r.proj/testsuite/test_rproj.py b/raster/r.proj/testsuite/test_rproj.py deleted file mode 100644 index 236d4ffac77..00000000000 --- a/raster/r.proj/testsuite/test_rproj.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python - -############################################################################## -# MODULE: r.proj -# -# AUTHOR(S): Chung-Yuan Liang -# -# PURPOSE: Unit tests for r.proj -# -# COPYRIGHT: (C) 2024 Chung-Yuan Liang and the GRASS Development Team -# -# This program is free software under the GNU General Public -# License (>=v2). Read the file COPYING that comes with GRASS -# for details. -############################################################################## - -from grass.gunittest.case import TestCase -from grass.gunittest.gmodules import call_module -import shutil -import json - -raster_info = """north=35.8096296297222 -south=35.6874074075 -east=-78.608 -west=-78.7746666666667 -nsres=0.000740740740740727 -ewres=0.000666666666666686 -rows=165 -cols=250 -cells=41250""" - -src_project = "nc_spm_full_v2beta1" -dst_project = "nc_latlong" - -raster_maps = [ - "landclass96", - "lsat7_2002_40", - "elevation", - "lsat7_2002_70", - "boundary_county_500m", - "basin", -] - - -class TestRasterreport(TestCase): - input = "elevation" - - @classmethod - def setUpClass(cls): - cls.runModule("g.proj", project=dst_project, epsg="4326", flags="c") - cls.runModule("g.mapset", mapset="PERMANENT", project=dst_project) - - @classmethod - def tearDownClass(cls): - cls.runModule("g.mapset", mapset="PERMANENT", project=src_project) - dbase = call_module("g.gisenv", get="GISDBASE") - shutil.rmtree(f"{dbase}/{dst_project}") - - def run_rproj_test(self, method, statics): - """The main function to run r.proj check rsults according to the method - - Parameters - ---------- - method : str - The method to be used for r.proj - statics : str - The expected statics of the output raster - """ - output = method - # Get the boundary and set up region for the projected map - flag_output = call_module( - "r.proj", - project=src_project, - mapset="PERMANENT", - input=self.input, - method=method, - flags="g", - ) - settings = dict([line.split("=") for line in flag_output.split()]) - - call_module( - "g.region", - n=settings["n"], - s=settings["s"], - e=settings["e"], - w=settings["w"], - rows=settings["rows"], - cols=settings["cols"], - flags="a", - res=1, - ) - - option_output = call_module( - "r.proj", - project=src_project, - mapset="PERMANENT", - input=self.input, - method=method, - flags="p", - format="shell", - ) - - self.assertEqual(flag_output, option_output) - - # Project the map - self.assertModule( - "r.proj", - project=src_project, - mapset="PERMANENT", - input=self.input, - output=output, - method=method, - quiet=True, - ) - - # Validate the output - self.assertRasterFitsUnivar(output, reference=statics, precision=1e-7) - self.assertRasterFitsInfo(output, reference=raster_info, precision=1e-7) - - def test_nearest(self): - """Testing method nearest""" - # Set up variables and validation values - method = "nearest" - statics = """n=40929 - min=55.5787925720215 - max=156.038833618164 - mean=110.377588991481 - variance=412.791070416939""" - - self.run_rproj_test(method, statics) - - def test_bilinear(self): - """Testing method bilinear""" - # Set up variables and validation values - method = "bilinear" - statics = """n=40846 - min=56.4586868286133 - max=156.053405761719 - mean=110.388441504306 - variance=411.490915467985""" - - self.run_rproj_test(method, statics) - - def test_bicubic(self): - """Testing method bicubic""" - # Set up variables and validation values - method = "bicubic" - statics = """n=40678 - min=56.2927856445312 - max=156.06169128418 - mean=110.417365826772 - variance=411.302683090515""" - - self.run_rproj_test(method, statics) - - def test_lanczos(self): - """Testing method lanczos""" - # Set up variables and validation values - method = "lanczos" - statics = """n=40587 - min=56.2883224487305 - max=156.066925048828 - mean=110.423209871101 - variance=411.631827343473""" - - self.run_rproj_test(method, statics) - - def test_bilinear_f(self): - """Testing method bilinear_f""" - # Set up variables and validation values - method = "bilinear_f" - statics = """n=40929 - min=55.5787925720215 - max=156.053405761719 - mean=110.376596053808 - variance=412.568369942043""" - - self.run_rproj_test(method, statics) - - def test_bicubic_f(self): - """Testing method bicubic_f""" - # Set up variables and validation values - method = "bicubic_f" - statics = """n=40929 - min=55.5787925720215 - max=156.06169128418 - mean=110.37642902228 - variance=412.70693471812""" - - self.run_rproj_test(method, statics) - - def test_lanczos_f(self): - """Testing method lanczos_f""" - # Set up variables and validation values - method = "lanczos_f" - statics = """n=40929 - min=55.5787925720215 - max=156.066925048828 - mean=110.376264598194 - variance=412.710534285851""" - - self.run_rproj_test(method, statics) - - def test_list_output_plain(self): - """Test plain output of available raster maps in input mapset .""" - result = call_module( - "r.proj", - project=src_project, - mapset="PERMANENT", - flags="l", - ) - result_list = result.split() - - for r_map in raster_maps: - self.assertIn( - r_map, result_list, f"'{r_map}' not found in raster map list (plain)" - ) - - def test_list_output_json(self): - """Test JSON output of available raster maps in input mapset.""" - output = call_module( - "r.proj", - project=src_project, - mapset="PERMANENT", - flags="l", - format="json", - ) - result = json.loads(output) - - for r_map in raster_maps: - self.assertIn( - r_map, result, f"'{r_map}' not found in raster map list (JSON)" - ) - - def test_print_output_plain(self): - """Test printing input map bounds in the current projection (plain format).""" - result = call_module( - "r.proj", - project=src_project, - mapset="PERMANENT", - input=self.input, - flags="p", - ).splitlines() - - expected = [ - "Source cols: 1500", - "Source rows: 1350", - "Local north: 35:48:34.619215N", - "Local south: 35:41:15.051632N", - "Local west: 78:46:28.642843W", - "Local east: 78:36:29.900338W", - ] - - self.assertListEqual(result, expected, "Mismatch in print output (plain)") - - def test_print_output_json(self): - """Test printing input map bounds in the current projection (JSON format).""" - output = call_module( - "r.proj", - project=src_project, - mapset="PERMANENT", - input=self.input, - flags="p", - format="json", - ) - result = json.loads(output) - - expected = { - "cols": 1500, - "east": -78.60830564948812, - "north": 35.80961644859374, - "rows": 1350, - "south": 35.68751434209047, - "west": -78.77462301207872, - } - - self.assertEqual( - result["cols"], expected["cols"], msg="Mismatch in print output (JSON)" - ) - self.assertAlmostEqual( - result["east"], expected["east"], msg="Mismatch in print output (JSON)" - ) - self.assertAlmostEqual( - result["north"], expected["north"], msg="Mismatch in print output (JSON)" - ) - self.assertEqual( - result["rows"], expected["rows"], msg="Mismatch in print output (JSON)" - ) - self.assertAlmostEqual( - result["south"], expected["south"], msg="Mismatch in print output (JSON)" - ) - self.assertAlmostEqual( - result["west"], expected["west"], msg="Mismatch in print output (JSON)" - ) - - -if __name__ == "__main__": - from grass.gunittest.main import test - - test() From 20d740076e39f2086779ad2021ca32561dd5b1a9 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Tue, 21 Jul 2026 19:31:11 -0700 Subject: [PATCH 19/32] r.proj: keep the input strip resident across consecutive bands 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. --- raster/r.proj/main.c | 118 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 25 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index f1329ba228f..bc04044fec6 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -1162,6 +1162,16 @@ int main(int argc, char **argv) int used_fallback = 0; /* set when the serial tile-cache fallback runs */ int force_tilecache = getenv("R_PROJ_FORCE_TILECACHE") != NULL; + /* Rolling-window input residency (Anna review item 1): keep one band's + * input strip resident and slide it down between consecutive single-tile + * bands, reading only the rows a band adds rather than re-reading its whole + * [imin,imax]. win holds input rows [win_imin, win_imax]; win_imax < + * win_imin marks the window empty/invalid (forces a full read). win_cap is + * the allocated byte size. Freed at fallback_done and after the band loop. + */ + unsigned char *win = NULL; + size_t win_cap = 0; + int win_imin = 0, win_imax = -1; int obr0 = 0; while (obr0 < outcellhd.rows) { /* Fit search. Phase 1 (fast path, unchanged): halve the band height @@ -1373,42 +1383,93 @@ int main(int argc, char **argv) /* Serial strip load (single fd -> get_row not thread-safe). EMPTY * TILE: strip_rows <= 0 -> projects outside input, no read; cells - * become NULL via interpolate_strip's out-of-map path. */ + * become NULL via interpolate_strip's out-of-map path, and the + * window is invalidated so the next band re-reads in full. */ void *strip = NULL; if (strip_rows > 0) { - strip = - G_malloc((size_t)strip_rows * incellhd.cols * cell_size); + size_t need = (size_t)strip_rows * incellhd.cols * cell_size; + size_t row_bytes = (size_t)incellhd.cols * cell_size; + /* Slide only for a serial-read, single-tile band whose window + * is valid and whose rows advance forward and still overlap the + * new span. read_nprocs == 1 is required: the parallel read + * chunks a full [imin, imax] span across per-thread fds, which + * this change does not re-certify for a partial tail (N>1 stays + * a full read). Tiled bands (spans jump per tile) and backward + * steps or gaps (pole/inverted frames) fall back to a full + * read, exactly today's behavior. */ + int can_slide = read_nprocs == 1 && n_tiles == 1 && + win_imax >= 0 && imin >= win_imin && + imin <= win_imax + 1; + int read_from = imin; + /* Grow FIRST, then memmove, then read the tail. G_realloc may + * move the buffer, so growing must precede the memmove that + * repositions the retained overlap inside it; realloc preserves + * the old rows at their old offsets, which the memmove then + * shifts to the new imin origin. When the span SHRINKS + * (imax < win_imax) the resident rows beyond imax are dropped + * from the window's accounting below, not kept -- a deliberate, + * conservative choice: a later band that needs them re-reads, + * and the window never claims rows it is not tracking. */ + if (need > win_cap) { + win = G_realloc(win, need); + win_cap = need; + } + if (can_slide && win_imax >= imin) { + memmove(win, win + (size_t)(imin - win_imin) * row_bytes, + (size_t)(win_imax - imin + 1) * row_bytes); + read_from = win_imax + 1; /* only new rows hit disk */ + } + strip = win; double t0 = rproj_wtime(); - G_switch_env(); /* -> input */ - if (read_nprocs > 1) { + if (read_from <= imax) { + G_switch_env(); /* -> input */ + if (read_nprocs > 1) { #ifdef _OPENMP - /* Parallel read: each thread reads a contiguous, disjoint - * block of strip rows through its OWN fd into its own - * disjoint strip slice. No two threads share an fd/row. */ + /* Parallel read (full-span only; can_slide is false + * here): each thread reads a contiguous, disjoint block + * of rows through its OWN fd into its own disjoint + * strip slice. No two threads share an fd/row. */ #pragma omp parallel num_threads(read_nprocs) - { - int t = omp_get_thread_num(); + { + int t = omp_get_thread_num(); #pragma omp for schedule(static) - for (int r = imin; r <= imax; r++) - Rast_get_row(fd_read[t], + for (int r = read_from; r <= imax; r++) + Rast_get_row(fd_read[t], + (unsigned char *)strip + + (size_t)(r - imin) * + incellhd.cols * cell_size, + r, cell_type); + } +#endif + } + else { + /* Serial read of the (possibly partial) tail. */ + for (int r = read_from; r <= imax; r++) + Rast_get_row(fdi, (unsigned char *)strip + (size_t)(r - imin) * incellhd.cols * cell_size, r, cell_type); } -#endif + G_switch_env(); /* -> output */ + } + t_fill += rproj_wtime() - t0; + /* Record what the window now tracks. A tiled band leaves win + * holding only its last tile, so invalidate BOTH fields to + * force the next band's full read; validity then never depends + * on && short-circuit order. */ + if (n_tiles == 1) { + win_imin = imin; + win_imax = imax; } else { - /* Serial fallback (nprocs==1, mask, or no OpenMP). */ - for (int r = imin; r <= imax; r++) - Rast_get_row(fdi, - (unsigned char *)strip + - (size_t)(r - imin) * incellhd.cols * - cell_size, - r, cell_type); + win_imin = 0; + win_imax = -1; } - G_switch_env(); /* -> output */ - t_fill += rproj_wtime() - t0; + } + else { + win_imin = 0; + win_imax = -1; /* empty tile: nothing resident */ } double t1 = rproj_wtime(); @@ -1454,9 +1515,9 @@ int main(int argc, char **argv) GPJ_free_transform_clone(&tproj_local); } t_compute += rproj_wtime() - t1; - - if (strip) - G_free(strip); + /* strip aliases the persistent window buffer (win); it is not freed + * per tile -- freed once at fallback_done and after the band loop. + */ } /* Serial in-order write of the band's rows once all tiles filled @@ -1477,6 +1538,13 @@ int main(int argc, char **argv) fallback_done: G_free(y_center); + /* Single free site for the rolling window: the band loop's only exits are + * normal completion (falls through to here) and the two goto fallback_done + * bails (band-0 early-out, Phase-2 width bust), all converging on this + * label, so one free covers every path crossing the window's live range. + * win is NULL if a bail fired before any band allocated it. */ + if (win) + G_free(win); if (used_fallback) G_debug(1, "PHASE_TIMERS fallback=1 fallback_from_row=%d", obr0); From 98a2a1c6d68392b0fcd00b9eb114eb7e4460feee Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 22 Jul 2026 16:46:00 -0700 Subject: [PATCH 20/32] r.proj: overlap the output write with the next band's compute 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. --- raster/r.proj/main.c | 185 +++++++++++++++++++++++++++++-------------- 1 file changed, 126 insertions(+), 59 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index bc04044fec6..abded44c5e7 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -320,18 +320,17 @@ static int est_worst_tile_strip_rows( * width whose worst input strip fits (setting *acc_tilew to that width, via the * same upper-tier estimate then lower-tier exact validation the search uses); * 0 if no width fits or the output buffer alone exceeds the cap. */ -static int phase2_width_fit(const struct Cell_head *ohd, - const struct Cell_head *ihd, - const struct pj_info *oproj, - const struct pj_info *iproj, - const struct pj_info *tproj, const double *y_center, - int obr0, int h, size_t cap_bytes, int cell_size, - int *acc_tilew, const struct pole_set *poles) +static int +phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, const double *y_center, int obr0, + int h, size_t cap_bytes, int cell_size, int out_mult, + int *acc_tilew, const struct pole_set *poles) { size_t out_bytes = (size_t)h * ohd->cols * cell_size; int tilew, est_fit; - if (out_bytes > cap_bytes) + if (out_mult * out_bytes > cap_bytes) return 0; tilew = ohd->cols; est_fit = 0; @@ -340,7 +339,7 @@ static int phase2_width_fit(const struct Cell_head *ohd, est_worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr0 + h, tilew, TILE_PROBE, poles); size_t est_bytes = est > 0 ? (size_t)est * ihd->cols * cell_size : 0; - if (est_bytes + out_bytes <= cap_bytes) { + if (est_bytes + out_mult * out_bytes <= cap_bytes) { est_fit = 1; break; } @@ -355,7 +354,7 @@ static int phase2_width_fit(const struct Cell_head *ohd, obr0, obr0 + h, tilew, poles); size_t strip_bytes = worst > 0 ? (size_t)worst * ihd->cols * cell_size : 0; - if (strip_bytes + out_bytes <= cap_bytes) { + if (strip_bytes + out_mult * out_bytes <= cap_bytes) { *acc_tilew = tilew; return 1; } @@ -376,19 +375,19 @@ static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, const double *y_center, int obr0, int h, size_t cap_bytes, int cell_size, - const struct pole_set *poles) + int out_mult, const struct pole_set *poles) { int imin, imax, strip_rows; size_t out_bytes = (size_t)h * ohd->cols * cell_size, strip_bytes; - if (out_bytes > cap_bytes) + if (out_mult * out_bytes > cap_bytes) return 0; band_input_row_span(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr0 + h, 0, ohd->cols, &imin, &imax, poles, NULL); strip_rows = imax - imin + 1; strip_bytes = strip_rows > 0 ? (size_t)strip_rows * ihd->cols * cell_size : 0; - return strip_bytes + out_bytes <= cap_bytes; + return strip_bytes + out_mult * out_bytes <= cap_bytes; } /* Serial tile-cache fallback for the large-halo/oblique corner: when even a @@ -445,6 +444,23 @@ fallback_serial_cache(int fdi, int fdo, int cell_type, int method, G_free(obuffer); } +/* Write the deferred band, if any, in order and release it. Used by the last + * band and the fallback bails so every path writes the deferred band the same + * way. */ +static void flush_pending_band(int fdo, int cell_type, int cols, int cell_size, + void **pending, int r0, int r1) +{ + if (*pending == NULL) + return; + for (int wr = r0; wr < r1; wr++) + Rast_put_row(fdo, + (unsigned char *)*pending + + (size_t)(wr - r0) * cols * cell_size, + cell_type); + G_free(*pending); + *pending = NULL; +} + int main(int argc, char **argv) { char *mapname, /* ptr to name of output layer */ @@ -1088,6 +1104,11 @@ int main(int argc, char **argv) * instead of the whole input map (Path A). */ double cap_mb = atof(memory->answer); size_t cap_bytes = (size_t)(cap_mb * 1024.0 * 1024.0); + /* Under write_overlap the overlapped writes run inside the compute region, + * so their wall time falls in t_compute. t_write then covers the + * non-overlapped writes only: the last band's flush (timed at + * fallback_done) and every band at N=1. Fallback bail flushes are untimed, + * but a fallback run reports fallback=1 rather than this phase split. */ double t_size = 0.0, t_fill = 0.0, t_compute = 0.0, t_write = 0.0; int n_bands = 0; int max_tiles = 1; /* most column tiles used by any single band */ @@ -1162,16 +1183,24 @@ int main(int argc, char **argv) int used_fallback = 0; /* set when the serial tile-cache fallback runs */ int force_tilecache = getenv("R_PROJ_FORCE_TILECACHE") != NULL; - /* Rolling-window input residency (Anna review item 1): keep one band's - * input strip resident and slide it down between consecutive single-tile - * bands, reading only the rows a band adds rather than re-reading its whole - * [imin,imax]. win holds input rows [win_imin, win_imax]; win_imax < - * win_imin marks the window empty/invalid (forces a full read). win_cap is - * the allocated byte size. Freed at fallback_done and after the band loop. - */ + /* Rolling input-strip window. win holds input rows [win_imin, win_imax]. + * win_imax < win_imin marks it empty and forces a full read. win_cap is + * its allocated byte size, and win is freed once at fallback_done. */ unsigned char *win = NULL; size_t win_cap = 0; int win_imin = 0, win_imax = -1; + /* One predicate for output double-buffering. The compute region runs + * want_nprocs threads (omp_get_max_threads(), not the masked read_nprocs), + * so overlap is possible only with more than one compute thread. out_mult + * reserves two output bands in the fit search and the omp single writer + * engages on the same flag, so the budget and the writer cannot diverge. */ + int write_overlap = want_nprocs > 1; + int out_mult = write_overlap ? 2 : 1; + /* Previous band's output buffer, written by one thread while the next + * band computes. NULL when nothing is pending; rows + * [pending_r0, pending_r1). */ + void *pending_out = NULL; + int pending_r0 = 0, pending_r1 = 0; int obr0 = 0; while (obr0 < outcellhd.rows) { /* Fit search. Phase 1 (fast path, unchanged): halve the band height @@ -1209,6 +1238,10 @@ int main(int argc, char **argv) cap_mb, worst1, (double)(strip1 + out1) / (1024.0 * 1024.0), obr0, outcellhd.rows - 1, needed_mb); + /* Flush the deferred band before the fallback writes from obr0 + * (in-order). */ + flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, + &pending_out, pending_r0, pending_r1); fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, &iproj, &tproj, &incellhd, &outcellhd, y_center, obr0, memory->answer); @@ -1236,7 +1269,7 @@ int main(int argc, char **argv) while ((gs + 1) / 2 > seed_h1) gs = (gs + 1) / 2; if (!phase1_fits(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, obr0, gs, cap_bytes, cell_size, + y_center, obr0, gs, cap_bytes, cell_size, out_mult, &poles)) { band_orows = (gs + 1) / 2; p1_seeded = 1; @@ -1251,7 +1284,8 @@ int main(int argc, char **argv) strip_rows > 0 ? (size_t)strip_rows * incellhd.cols * cell_size : 0; size_t out_bytes = (size_t)band_orows * outcellhd.cols * cell_size; - if (!force_tilecache && strip_bytes + out_bytes <= cap_bytes) + if (!force_tilecache && + strip_bytes + out_mult * out_bytes <= cap_bytes) break; if (band_orows == 1) break; /* height exhausted: fall through to column splitting */ @@ -1286,7 +1320,7 @@ int main(int argc, char **argv) gs = (gs + 1) / 2; if (!phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, &tproj, y_center, obr0, gs, cap_bytes, - cell_size, &w, &poles)) { + cell_size, out_mult, &w, &poles)) { start_h = (gs + 1) / 2; seed_hits++; } @@ -1296,7 +1330,8 @@ int main(int argc, char **argv) if (!force_tilecache && phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, &tproj, y_center, obr0, band_orows, - cap_bytes, cell_size, &tilew, &poles)) + cap_bytes, cell_size, out_mult, &tilew, + &poles)) break; if (band_orows == 1) { /* Single output row at minimum width still over cap = @@ -1333,6 +1368,12 @@ int main(int argc, char **argv) (double)(strip_bytes + out1) / (1024.0 * 1024.0), obr0, outcellhd.rows - 1, needed_mb); } + /* Flush the deferred band before the fallback writes from + * obr0 (in-order). This band's compute region did not run, + * so its omp single did not write the previous band. */ + flush_pending_band(fdo, cell_type, outcellhd.cols, + cell_size, &pending_out, pending_r0, + pending_r1); fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, &iproj, &tproj, &incellhd, &outcellhd, y_center, obr0, memory->answer); @@ -1389,27 +1430,20 @@ int main(int argc, char **argv) if (strip_rows > 0) { size_t need = (size_t)strip_rows * incellhd.cols * cell_size; size_t row_bytes = (size_t)incellhd.cols * cell_size; - /* Slide only for a serial-read, single-tile band whose window - * is valid and whose rows advance forward and still overlap the - * new span. read_nprocs == 1 is required: the parallel read - * chunks a full [imin, imax] span across per-thread fds, which - * this change does not re-certify for a partial tail (N>1 stays - * a full read). Tiled bands (spans jump per tile) and backward - * steps or gaps (pole/inverted frames) fall back to a full - * read, exactly today's behavior. */ + /* Slide only for a serial-read single-tile band whose rows + * advance forward and still overlap the window. Anything else + * does a full read, same as before. */ int can_slide = read_nprocs == 1 && n_tiles == 1 && win_imax >= 0 && imin >= win_imin && imin <= win_imax + 1; int read_from = imin; - /* Grow FIRST, then memmove, then read the tail. G_realloc may - * move the buffer, so growing must precede the memmove that - * repositions the retained overlap inside it; realloc preserves - * the old rows at their old offsets, which the memmove then - * shifts to the new imin origin. When the span SHRINKS - * (imax < win_imax) the resident rows beyond imax are dropped - * from the window's accounting below, not kept -- a deliberate, - * conservative choice: a later band that needs them re-reads, - * and the window never claims rows it is not tracking. */ + /* Grow first, then memmove, then read the tail. G_realloc may + * move the buffer, so it must run before the memmove that + * repositions the retained overlap. Realloc preserves the old + * rows at their old offsets, and the memmove shifts them to the + * new imin origin. If the span shrinks (imax < win_imax), the + * rows past imax are dropped from win_imax below rather than + * kept, so a later band that needs them re-reads them. */ if (need > win_cap) { win = G_realloc(win, need); win_cap = need; @@ -1454,10 +1488,10 @@ int main(int argc, char **argv) G_switch_env(); /* -> output */ } t_fill += rproj_wtime() - t0; - /* Record what the window now tracks. A tiled band leaves win - * holding only its last tile, so invalidate BOTH fields to - * force the next band's full read; validity then never depends - * on && short-circuit order. */ + /* Record what the window now holds. A tiled band leaves win + * with only its last tile, so invalidate both fields to force + * the next band's full read. Setting both keeps validity + * independent of && short-circuit order. */ if (n_tiles == 1) { win_imin = imin; win_imax = imax; @@ -1482,6 +1516,20 @@ int main(int argc, char **argv) struct gpj_transform_clone tproj_local; GPJ_clone_transform(&tproj, &tproj_local); +#pragma omp single nowait + { + /* One thread writes the previous band's rows in order while + * the rest compute this band. First tile only, and + * pending_out is non-NULL only under write_overlap. */ + if (obc0 == 0 && pending_out) + for (int wr = pending_r0; wr < pending_r1; wr++) + Rast_put_row(fdo, + (unsigned char *)pending_out + + (size_t)(wr - pending_r0) * + outcellhd.cols * cell_size, + cell_type); + } + #pragma omp for private(row, col) schedule(dynamic) for (row = obr0; row < obr1; row++) { void *out_row = @@ -1515,28 +1563,47 @@ int main(int argc, char **argv) GPJ_free_transform_clone(&tproj_local); } t_compute += rproj_wtime() - t1; - /* strip aliases the persistent window buffer (win); it is not freed - * per tile -- freed once at fallback_done and after the band loop. - */ + /* strip aliases the persistent window buffer win, so it is not + * freed per tile. win is freed once at fallback_done. */ } - /* Serial in-order write of the band's rows once all tiles filled - * band_out (Rast_put_row sequential). */ - double t2 = rproj_wtime(); - for (row = obr0; row < obr1; row++) - Rast_put_row(fdo, - (unsigned char *)band_out + - (size_t)(row - obr0) * outcellhd.cols * cell_size, - cell_type); - t_write += rproj_wtime() - t2; + /* Defer this band so the next band's compute region writes it (via the + * omp single above). The previous pending was written in this band's + * compute region and completed at that region's barrier, so free it + * now. Non-overlap bands write and free in order here. */ + if (write_overlap) { + if (pending_out) + G_free(pending_out); + pending_out = band_out; + pending_r0 = obr0; + pending_r1 = obr1; + } + else { + double t2 = rproj_wtime(); + for (row = obr0; row < obr1; row++) + Rast_put_row(fdo, + (unsigned char *)band_out + (size_t)(row - obr0) * + outcellhd.cols * + cell_size, + cell_type); + t_write += rproj_wtime() - t2; + G_free(band_out); + } G_percent(obr1, outcellhd.rows, 5); - - G_free(band_out); obr0 = obr1; } fallback_done: + /* Flush the last band's deferred write on normal completion, timed into + * t_write. The fallback bails flush before fallback_serial_cache, so + * pending_out is NULL here on those paths. */ + { + double tw = rproj_wtime(); + flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, + &pending_out, pending_r0, pending_r1); + t_write += rproj_wtime() - tw; + } G_free(y_center); /* Single free site for the rolling window: the band loop's only exits are * normal completion (falls through to here) and the two goto fallback_done From e0f8732c0a78d5b5929b8206779420f4ec8db46d Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 22 Jul 2026 18:17:11 -0700 Subject: [PATCH 21/32] r.proj: move method reference tests to a separate PR 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. --- raster/r.proj/tests/conftest.py | 4 + raster/r.proj/tests/r_proj_methods_test.py | 145 ---------- raster/r.proj/testsuite/test_rproj.py | 300 +++++++++++++++++++++ 3 files changed, 304 insertions(+), 145 deletions(-) delete mode 100644 raster/r.proj/tests/r_proj_methods_test.py create mode 100644 raster/r.proj/testsuite/test_rproj.py diff --git a/raster/r.proj/tests/conftest.py b/raster/r.proj/tests/conftest.py index cdd5800dc82..c50290414cf 100644 --- a/raster/r.proj/tests/conftest.py +++ b/raster/r.proj/tests/conftest.py @@ -22,6 +22,10 @@ time budget. """ +# Duplicated from the companion test-split PR (branch fix-rproj-tests), +# which owns this file alongside the method reference tests. Drop this copy +# when that PR merges into main; the fixtures are identical. + import os import pytest diff --git a/raster/r.proj/tests/r_proj_methods_test.py b/raster/r.proj/tests/r_proj_methods_test.py deleted file mode 100644 index c88c863dafa..00000000000 --- a/raster/r.proj/tests/r_proj_methods_test.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Reference-value test for r.proj across interpolation methods. - -For every interpolation method, r.proj is run at OMP_NUM_THREADS=1 on the -generated input_mid raster reprojected into EPSG:3857, and its univariate -statistics are compared against reference values. This verifies that changes -such as the OpenMP parallelization do not change the serial output. - -Reference provenance: values captured from r.proj.serial (md5 -cde07de7d7b2e2798fb203fc01b27714), the frozen main-branch serial binary, run -at OMP_NUM_THREADS=1 on the session_3857 / input_mid fixture data with the -output region set from r.proj -g. The single-thread run matches the -single-thread reference. All seven references are pairwise distinct, and every -pairwise method swap is catchable at the primary rel=1e-7 tolerance (the -input_mid curvature term is chosen so bilinear_f and bicubic_f diverge past -1e-7). - -The rel=1e-6 relaxation pre-registered below, if ever invoked on a CI -floating-point flutter, sacrifices the bilinear_f/bicubic_f swap detection -(their means differ by only ~2.6e-7 relative, under 1e-6) -- so relaxing is a -visible trade, not a silent one. -""" - -import pytest - -import grass.script as gs - -SRC_PROJECT = "src4326" -INPUT_MID = "input_mid" - -REFERENCE = { - "nearest": { - "n": 2500, - "null_cells": 0, - "min": 103, - "max": 5058, - "mean": 2581.0344, - "stddev": 1443.17823750798, - }, - "bilinear": { - "n": 2352, - "null_cells": 148, - "min": 202.023132324219, - "max": 5054.64990234375, - "mean": 2624.75047491683, - "stddev": 1414.50216671975, - }, - "bicubic": { - "n": 2162, - "null_cells": 338, - "min": 306.120361328125, - "max": 4958.90966796875, - "mean": 2624.59341703092, - "stddev": 1356.74263472616, - }, - "lanczos": { - "n": 2116, - "null_cells": 384, - "min": 305.625366210938, - "max": 4851.7939453125, - "mean": 2572.88704589094, - "stddev": 1327.61055457531, - }, - "bilinear_f": { - "n": 2500, - "null_cells": 0, - "min": 103, - "max": 5058, - "mean": 2575.07724680176, - "stddev": 1443.17041934389, - }, - "bicubic_f": { - "n": 2500, - "null_cells": 0, - "min": 103, - "max": 5058, - "mean": 2575.0779140625, - "stddev": 1443.17057576919, - }, - "lanczos_f": { - "n": 2500, - "null_cells": 0, - "min": 103, - "max": 5058, - "mean": 2573.76246621094, - "stddev": 1443.17228983607, - }, -} - -METHODS = list(REFERENCE) - - -def _set_region_from_source(env): - """Set the output region to r.proj's suggested bounds (method-independent).""" - text = gs.read_command( - "r.proj", - project=SRC_PROJECT, - mapset="PERMANENT", - input=INPUT_MID, - method="nearest", - flags="g", - env=env, - ) - region = dict(token.split("=") for token in text.split()) - gs.run_command( - "g.region", - n=region["n"], - s=region["s"], - e=region["e"], - w=region["w"], - rows=region["rows"], - cols=region["cols"], - env=env, - ) - - -@pytest.mark.parametrize("method", METHODS) -def test_method_matches_serial_reference(session_3857, method): - """r.proj serial output stats must match the captured serial reference.""" - env = dict(session_3857.env) - env["OMP_NUM_THREADS"] = "1" - _set_region_from_source(env) - - output = f"ref_{method}" - gs.run_command( - "r.proj", - project=SRC_PROJECT, - mapset="PERMANENT", - input=INPUT_MID, - output=output, - method=method, - overwrite=True, - quiet=True, - env=env, - ) - stats = gs.parse_command("r.univar", map=output, flags="g", env=env) - reference = REFERENCE[method] - - assert int(stats["n"]) == reference["n"] - assert int(stats["null_cells"]) == reference["null_cells"] - # Primary tolerance rel=1e-7. The only permitted relaxation, on an actual - # CI floating-point flutter (naming the platform that showed it), is Anna's - # r.param.scale tolerance rel=1e-6, abs=5e-8; see the module docstring for - # the bilinear_f/bicubic_f detection it trades away. - for field in ("min", "max", "mean", "stddev"): - assert float(stats[field]) == pytest.approx(reference[field], rel=1e-7) diff --git a/raster/r.proj/testsuite/test_rproj.py b/raster/r.proj/testsuite/test_rproj.py new file mode 100644 index 00000000000..236d4ffac77 --- /dev/null +++ b/raster/r.proj/testsuite/test_rproj.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python + +############################################################################## +# MODULE: r.proj +# +# AUTHOR(S): Chung-Yuan Liang +# +# PURPOSE: Unit tests for r.proj +# +# COPYRIGHT: (C) 2024 Chung-Yuan Liang and the GRASS Development Team +# +# This program is free software under the GNU General Public +# License (>=v2). Read the file COPYING that comes with GRASS +# for details. +############################################################################## + +from grass.gunittest.case import TestCase +from grass.gunittest.gmodules import call_module +import shutil +import json + +raster_info = """north=35.8096296297222 +south=35.6874074075 +east=-78.608 +west=-78.7746666666667 +nsres=0.000740740740740727 +ewres=0.000666666666666686 +rows=165 +cols=250 +cells=41250""" + +src_project = "nc_spm_full_v2beta1" +dst_project = "nc_latlong" + +raster_maps = [ + "landclass96", + "lsat7_2002_40", + "elevation", + "lsat7_2002_70", + "boundary_county_500m", + "basin", +] + + +class TestRasterreport(TestCase): + input = "elevation" + + @classmethod + def setUpClass(cls): + cls.runModule("g.proj", project=dst_project, epsg="4326", flags="c") + cls.runModule("g.mapset", mapset="PERMANENT", project=dst_project) + + @classmethod + def tearDownClass(cls): + cls.runModule("g.mapset", mapset="PERMANENT", project=src_project) + dbase = call_module("g.gisenv", get="GISDBASE") + shutil.rmtree(f"{dbase}/{dst_project}") + + def run_rproj_test(self, method, statics): + """The main function to run r.proj check rsults according to the method + + Parameters + ---------- + method : str + The method to be used for r.proj + statics : str + The expected statics of the output raster + """ + output = method + # Get the boundary and set up region for the projected map + flag_output = call_module( + "r.proj", + project=src_project, + mapset="PERMANENT", + input=self.input, + method=method, + flags="g", + ) + settings = dict([line.split("=") for line in flag_output.split()]) + + call_module( + "g.region", + n=settings["n"], + s=settings["s"], + e=settings["e"], + w=settings["w"], + rows=settings["rows"], + cols=settings["cols"], + flags="a", + res=1, + ) + + option_output = call_module( + "r.proj", + project=src_project, + mapset="PERMANENT", + input=self.input, + method=method, + flags="p", + format="shell", + ) + + self.assertEqual(flag_output, option_output) + + # Project the map + self.assertModule( + "r.proj", + project=src_project, + mapset="PERMANENT", + input=self.input, + output=output, + method=method, + quiet=True, + ) + + # Validate the output + self.assertRasterFitsUnivar(output, reference=statics, precision=1e-7) + self.assertRasterFitsInfo(output, reference=raster_info, precision=1e-7) + + def test_nearest(self): + """Testing method nearest""" + # Set up variables and validation values + method = "nearest" + statics = """n=40929 + min=55.5787925720215 + max=156.038833618164 + mean=110.377588991481 + variance=412.791070416939""" + + self.run_rproj_test(method, statics) + + def test_bilinear(self): + """Testing method bilinear""" + # Set up variables and validation values + method = "bilinear" + statics = """n=40846 + min=56.4586868286133 + max=156.053405761719 + mean=110.388441504306 + variance=411.490915467985""" + + self.run_rproj_test(method, statics) + + def test_bicubic(self): + """Testing method bicubic""" + # Set up variables and validation values + method = "bicubic" + statics = """n=40678 + min=56.2927856445312 + max=156.06169128418 + mean=110.417365826772 + variance=411.302683090515""" + + self.run_rproj_test(method, statics) + + def test_lanczos(self): + """Testing method lanczos""" + # Set up variables and validation values + method = "lanczos" + statics = """n=40587 + min=56.2883224487305 + max=156.066925048828 + mean=110.423209871101 + variance=411.631827343473""" + + self.run_rproj_test(method, statics) + + def test_bilinear_f(self): + """Testing method bilinear_f""" + # Set up variables and validation values + method = "bilinear_f" + statics = """n=40929 + min=55.5787925720215 + max=156.053405761719 + mean=110.376596053808 + variance=412.568369942043""" + + self.run_rproj_test(method, statics) + + def test_bicubic_f(self): + """Testing method bicubic_f""" + # Set up variables and validation values + method = "bicubic_f" + statics = """n=40929 + min=55.5787925720215 + max=156.06169128418 + mean=110.37642902228 + variance=412.70693471812""" + + self.run_rproj_test(method, statics) + + def test_lanczos_f(self): + """Testing method lanczos_f""" + # Set up variables and validation values + method = "lanczos_f" + statics = """n=40929 + min=55.5787925720215 + max=156.066925048828 + mean=110.376264598194 + variance=412.710534285851""" + + self.run_rproj_test(method, statics) + + def test_list_output_plain(self): + """Test plain output of available raster maps in input mapset .""" + result = call_module( + "r.proj", + project=src_project, + mapset="PERMANENT", + flags="l", + ) + result_list = result.split() + + for r_map in raster_maps: + self.assertIn( + r_map, result_list, f"'{r_map}' not found in raster map list (plain)" + ) + + def test_list_output_json(self): + """Test JSON output of available raster maps in input mapset.""" + output = call_module( + "r.proj", + project=src_project, + mapset="PERMANENT", + flags="l", + format="json", + ) + result = json.loads(output) + + for r_map in raster_maps: + self.assertIn( + r_map, result, f"'{r_map}' not found in raster map list (JSON)" + ) + + def test_print_output_plain(self): + """Test printing input map bounds in the current projection (plain format).""" + result = call_module( + "r.proj", + project=src_project, + mapset="PERMANENT", + input=self.input, + flags="p", + ).splitlines() + + expected = [ + "Source cols: 1500", + "Source rows: 1350", + "Local north: 35:48:34.619215N", + "Local south: 35:41:15.051632N", + "Local west: 78:46:28.642843W", + "Local east: 78:36:29.900338W", + ] + + self.assertListEqual(result, expected, "Mismatch in print output (plain)") + + def test_print_output_json(self): + """Test printing input map bounds in the current projection (JSON format).""" + output = call_module( + "r.proj", + project=src_project, + mapset="PERMANENT", + input=self.input, + flags="p", + format="json", + ) + result = json.loads(output) + + expected = { + "cols": 1500, + "east": -78.60830564948812, + "north": 35.80961644859374, + "rows": 1350, + "south": 35.68751434209047, + "west": -78.77462301207872, + } + + self.assertEqual( + result["cols"], expected["cols"], msg="Mismatch in print output (JSON)" + ) + self.assertAlmostEqual( + result["east"], expected["east"], msg="Mismatch in print output (JSON)" + ) + self.assertAlmostEqual( + result["north"], expected["north"], msg="Mismatch in print output (JSON)" + ) + self.assertEqual( + result["rows"], expected["rows"], msg="Mismatch in print output (JSON)" + ) + self.assertAlmostEqual( + result["south"], expected["south"], msg="Mismatch in print output (JSON)" + ) + self.assertAlmostEqual( + result["west"], expected["west"], msg="Mismatch in print output (JSON)" + ) + + +if __name__ == "__main__": + from grass.gunittest.main import test + + test() From b7904b9b50b3a05179b82164560683273b92a724 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 22 Jul 2026 20:58:02 -0700 Subject: [PATCH 22/32] r.proj: add nprocs option and clean up comments 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. --- raster/r.proj/main.c | 423 +++++++++----------- raster/r.proj/tests/r_proj_parallel_test.py | 53 ++- 2 files changed, 216 insertions(+), 260 deletions(-) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index abded44c5e7..eecb031845b 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -93,12 +93,11 @@ struct menu menu[] = { static char *make_ipol_list(void); static char *make_ipol_desc(void); -/* Nearest read from an in-RAM input STRIP holding input rows [imin, imax]. - * col_idx/row_idx are full-map input indices; the strip is addressed relative - * to imin. A sample inside the full input map but outside the loaded strip - * means the band footprint was under-sized: this is the stop-on-divergence - * trip (must never fire if band_input_row_span is correct). Lock-free: reads - * only, disjoint output slots per thread. */ +/* Nearest-neighbor read from an in-RAM strip holding input rows [imin, imax]. + * The col_idx and row_idx values are full-map indices and the strip is + * addressed relative to imin. A sample that lands inside the input map but + * outside the loaded strip means the band was under-sized, which the guard + * below catches. */ static void interpolate_strip(void *strip, void *obufptr, int cell_type, double col_idx, double row_idx, struct Cell_head *incellhd, int imin, int imax) @@ -107,18 +106,15 @@ static void interpolate_strip(void *strip, void *obufptr, int cell_type, int r = (int)floor(row_idx); int cell_size = Rast_cell_size(cell_type); - /* Outside the full input map: legitimate NULL (same as p_nearest). */ + /* A sample outside the input map is a legitimate NULL, like p_nearest. */ if (r < 0 || r >= incellhd->rows || c < 0 || c >= incellhd->cols) { Rast_set_null_value(obufptr, 1, cell_type); return; } - /* This input row is inside the input map (the check above already handled - * coordinates that fall outside it), but it is not among the rows we - * preloaded into this band's strip. That cannot happen if the band's - * footprint estimate was right, so it means the estimate was wrong: a bug - * in band sizing, not a normal case. Fail loudly rather than write a NULL - * and silently produce wrong output. */ + /* The band footprint was under-sized when a needed input row lies inside + * the input map but outside the loaded strip, so it fails loudly rather + * than emit a wrong NULL. */ if (r < imin || r > imax) G_fatal_error(_("Band strip under-sized: input row %d outside loaded " "range [%d, %d] at column %d"), @@ -130,37 +126,33 @@ static void interpolate_strip(void *strip, void *obufptr, int cell_type, memcpy(obufptr, src, cell_size); } -/* Strip-based kernels for the banded compute path, in the same order as menu[]: - * slot i is the strip counterpart of menu[i].method. Slot 0 is nearest - * (interpolate_strip above); slots 1-6 are the interp_strip.c kernels. */ +/* Strip kernels in the same order as menu[], so slot i is the strip counterpart + * of menu[i].method. Slot 0 is nearest above and slots 1 to 6 come from + * interp_strip.c. */ static const strip_func strip_kernels[] = { interpolate_strip, strip_bilinear, strip_cubic, strip_lanczos, strip_bilinear_f, strip_cubic_f, strip_lanczos_f}; -/* Geographic poles within the input map's latitude coverage. - * band_input_row_span samples only the tile perimeter, so a tile whose interior - * holds a pole has an input-row (latitude) extremum the perimeter misses; the - * pole's row is folded into that tile's span. Each pole is stored as its - * output-CRS coordinate (for a point-in-tile test) and its input row. Filled - * once per map, and empty (n == 0) whenever no pole is in frame, so pole - * handling is a no-op on such maps. Assumes a pole maps to a single output - * point (azimuthal/stereographic); for a projection that images a pole as a - * line or arc, the under-size guard remains the backstop. */ +/* Geographic poles inside the input's latitude coverage. band_input_row_span + * walks only the tile perimeter, so a pole in a tile's interior is a latitude + * extremum the walk misses, and the pole's row is folded into that tile's span. + * Each pole is stored as its output-CRS coordinate and its input row. The set + * is empty when no pole is in frame. This assumes a pole maps to one output + * point as in azimuthal and stereographic projections, and otherwise the + * under-size guard stays the backstop. */ struct pole_set { int n; /* active poles, 0..2 */ double ox[2], oy[2]; /* pole coordinates in the output CRS */ double ri[2]; /* pole input row index */ }; -/* Dense edge-walk of an output tile's rectangle [obr0, obr1) x [obc0, obc1) - * projected into input space; returns the min/max INPUT ROW touched, plus a - * 2-cell margin, clamped to the input map. Samples the tile's top and bottom - * rows across its columns [obc0, obc1) and its left and right columns across - * its rows (bordwalk-style), so a curved transform's interior-edge extremum is - * caught -- corner-only sampling can under-size the strip. A full-width band is - * the case obc0=0, obc1=cols. Called serially, before the parallel region, so - * the shared tproj is safe here. Returns imax < imin for a tile that projects - * entirely outside the input. */ +/* Edge-walk of an output tile [obr0, obr1) by [obc0, obc1) projected into input + * space, returning the min and max input row it touches plus a 2-cell margin, + * clamped to the input map. It walks the tile perimeter of top and bottom rows + * and left and right columns so a curved transform's interior-edge extremum is + * caught, which corner-only sampling would miss. It runs serially before the + * parallel region, so the shared tproj is safe. It returns imax below imin when + * the tile projects entirely outside the input. */ static void band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, @@ -204,12 +196,11 @@ band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, } } - /* Fold in any pole whose output point lies in this tile's rect: the - * perimeter walk cannot see an interior latitude extremum. A pole exactly - * on a tile edge (inclusive test) is caught by both adjacent tiles, which - * is harmless -- it only widens a strip that is loaded anyway. Placed - * before the empty-tile check so a pole inside an otherwise-outside tile - * still yields a valid span. */ + /* Fold in any pole whose output point lies in this tile, since the + * perimeter walk cannot see an interior latitude extremum. A pole on a tile + * edge is caught by both adjacent tiles, which only widens a strip that is + * loaded anyway. This comes before the empty-tile check so a pole inside an + * otherwise outside tile still yields a valid span. */ if (poles) { double x_lo = ohd->west + obc0 * ohd->ew_res; double x_hi = ohd->west + obc1 * ohd->ew_res; @@ -246,13 +237,11 @@ band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, *imax = hi; } -/* Largest input-row strip (in rows) among the column tiles of width tilew that - * partition output columns [0, ohd->cols) for the band [obr0, obr1). Tiles are - * loaded one at a time, so peak strip memory is set by the worst tile, not the - * union of the band's tiles; the fit search sizes this against the cap. Every - * call is a full tile edge-walk, so this is O(tiles * perimeter) -- paid in the - * serial size phase, and only when column splitting is actually entered. - * Returns 0 if every tile projects entirely outside the input. */ +/* Largest input-row strip among the width-tilew column tiles that partition the + * band [obr0, obr1). Tiles load one at a time, so peak strip memory is the + * worst tile rather than the union of the band's tiles, and the fit search + * sizes this against the cap. It returns 0 when every tile projects entirely + * outside the input. */ static int worst_tile_strip_rows(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, @@ -280,11 +269,11 @@ static int worst_tile_strip_rows(const struct Cell_head *ohd, #define TILE_PROBE 16 /* tiles sampled by the Phase-2 width-search estimate */ -/* Cheap estimate of worst_tile_strip_rows: the largest input-row strip among - * at most `probe` column tiles, evenly spaced across the band width and always - * including the first and last. A subset max is a LOWER bound on the true - * worst, so it only PRUNES the Phase-2 search; the chosen width is exact- - * validated by worst_tile_strip_rows before use. */ +/* Cheap lower-bound estimate of worst_tile_strip_rows, taking the largest strip + * among at most probe column tiles that are evenly spaced across the band width + * and always include the first and last. A subset max only prunes the Phase-2 + * search, and the chosen width is exact-validated by worst_tile_strip_rows + * before use. */ static int est_worst_tile_strip_rows( const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, @@ -315,11 +304,10 @@ static int est_worst_tile_strip_rows( return worst; } -/* Exact per-height fit test for the Phase-2 height search: 1 iff a band of - * height h at obr0 has an output buffer within the cap AND some column-tile - * width whose worst input strip fits (setting *acc_tilew to that width, via the - * same upper-tier estimate then lower-tier exact validation the search uses); - * 0 if no width fits or the output buffer alone exceeds the cap. */ +/* Phase-2 fit test that returns 1 when a band of height h at obr0 fits the cap + * at some column-tile width, setting acc_tilew to that width through the same + * estimate then exact-validate steps the search uses. It returns 0 when no + * width fits or the output buffer alone exceeds the cap. */ static int phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, @@ -366,11 +354,9 @@ phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, return 0; } -/* Full-width fit test for the Phase-1 height search: 1 iff a band of height h - * at obr0 has its full-width input strip plus output buffer within the cap. - * Short-circuits on the output buffer alone (no edge walk) when it already - * exceeds the cap. Used only by the seed peek; the walk keeps its inline test, - * so the miss path is byte-for-byte today's execution. */ +/* Phase-1 fit test that returns 1 when a band of height h at obr0 fits its + * full-width input strip plus output buffer inside the cap. It short-circuits + * on the output buffer alone and is used only by the seed peek. */ static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, const double *y_center, @@ -390,21 +376,12 @@ static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, return strip_bytes + out_mult * out_bytes <= cap_bytes; } -/* Serial tile-cache fallback for the large-halo/oblique corner: when even a - * single output row's full-width input strip busts the memory cap (the bail in - * the band loop), the banded strip path cannot proceed. This finishes the run - * from output row obr0 onward using the classic readcell block cache (faults - * blocks on demand, bounded by the same memory option via nblocks) and the - * CVAL cache kernels (menu[].method), exactly as the serial r.proj does. - * - * Runs strictly serially: get_block mutates shared cache state and is not - * thread-safe. Rows [0, obr0) were already written by the banded path; each - * output row is independent of the others, so the banded prefix followed by - * this serial suffix is bit-identical to a pure serial run. y_center supplies - * the same output-row northings the banded prefix used (and that serial's - * ycoord2 recurrence produces), so the seam at obr0 is seamless. A transform - * failure sets NULL here (matching the banded strip path) rather than the old - * serial fatal; identical on data where transforms succeed. */ +/* Serial tile-cache fallback for the oblique and large-halo corner. When even + * one output row's full-width strip busts the cap, this finishes the run from + * row obr0 with the classic readcell cache and CVAL kernels, exactly as serial + * r.proj does. It stays serial because get_block mutates shared cache state, + * and since the banded path already wrote the earlier rows the result matches + * a pure serial run. */ static void fallback_serial_cache(int fdi, int fdo, int cell_type, int method, const struct pj_info *oproj, const struct pj_info *iproj, @@ -444,7 +421,7 @@ fallback_serial_cache(int fdi, int fdo, int cell_type, int method, G_free(obuffer); } -/* Write the deferred band, if any, in order and release it. Used by the last +/* Write the deferred band, if any, in order and release it. Shared by the last * band and the fallback bails so every path writes the deferred band the same * way. */ static void flush_pending_band(int fdo, int cell_type, int cols, int cell_size, @@ -461,6 +438,14 @@ static void flush_pending_band(int fdo, int cell_type, int cols, int cell_size, *pending = NULL; } +/* Thread count for compute and write overlap, where nprocs above zero + * overrides OMP_NUM_THREADS. Set before the fit search so the write overlap's + * two reserved output buffers match the band sizing. */ +static int compute_nprocs(struct Option *nprocs) +{ + return G_set_omp_num_threads(nprocs); +} + int main(int argc, char **argv) { char *mapname, /* ptr to name of output layer */ @@ -508,6 +493,7 @@ int main(int argc, char **argv) *indbase, /* name of input database */ *interpol, /* interpolation method */ *memory, /* amount of memory for cache */ + *nprocs, /* number of compute threads */ *res, /* resolution of target map */ *format; /* output format */ @@ -565,6 +551,13 @@ int main(int argc, char **argv) memory = G_define_standard_option(G_OPT_MEMORYMB); + nprocs = G_define_standard_option(G_OPT_M_NPROCS); + nprocs->description = _( + "Number of threads for parallel computing. 0, the default, uses the " + "OpenMP default and honors OMP_NUM_THREADS if set. A value above zero " + "overrides OMP_NUM_THREADS, and a value below zero leaves that many " + "cores free"); + res = G_define_option(); res->key = "resolution"; res->type = TYPE_DOUBLE; @@ -1040,9 +1033,9 @@ int main(int argc, char **argv) G_message(_("NS-res: %f"), outcellhd.ns_res); G_message(" "); - /* Open the input map (input location env). Banding loads only per-band - * input strips, not the whole map, so fdi stays open across the band loop. - */ + /* Open the input map in the input env. Banding loads only per-band input + * strips rather than the whole map, so fdi stays open across the band + * loop. */ G_switch_env(); Rast_set_input_window(&incellhd); fdi = Rast_open_old(inmap->answer, setname); @@ -1051,24 +1044,16 @@ int main(int argc, char **argv) cell_type = FCELL_TYPE; cell_size = Rast_cell_size(cell_type); - /* Parallel input reads: decide the read-thread count here, in the INPUT - * env, so the mask guard checks the source mapset's mask (the mask that - * would apply to Rast_get_row on fdi). Rast_disable_omp_on_mask returns 1 - * (serial) if a mask is present or without OpenMP, and does NOT touch the - * thread count when no mask exists (lib/raster/mask_info.c:226-231), so the - * compute region's threads are unperturbed in the common case. When - * read_nprocs > 1 we open that many FRESH read fds (one per thread); fdi is - * used only by the serial fallback. - * INFERRED-safe (not yet runtime-verified; the gate converts it): - * concurrent Rast_open_old fds on the same map across locations is sound - * from the r.neighbors same-location precedent (in_fd[t]) plus the Stage 1 - * fcb analysis (each fd carries its own cur_row/data/data_fd; reads depend - * only on the fcb and R__.rd_window). */ -#ifdef _OPENMP - int want_nprocs = omp_get_max_threads(); -#else - int want_nprocs = 1; -#endif + /* The read-thread count is decided here in the input env so the mask guard + * checks the source mapset's mask. Rast_disable_omp_on_mask returns 1 and + * forces serial under a mask or without OpenMP, and leaves the count + * untouched otherwise (lib/raster/mask_info.c lines 226-231). When + * read_nprocs is above one, each thread opens its own fresh fd and fdi + * serves only the serial fallback. Concurrent fds on the same map across + * locations follow the r.neighbors in_fd[] precedent, where each fd carries + * its own cur_row, data, and data_fd. */ + /* Runs before the fit search. See compute_nprocs(). */ + int want_nprocs = compute_nprocs(nprocs); int read_nprocs = Rast_disable_omp_on_mask(want_nprocs); int *fd_read = NULL; if (read_nprocs > 1) { @@ -1077,10 +1062,9 @@ int main(int argc, char **argv) fd_read[t] = Rast_open_old(inmap->answer, setname); } - /* Back to the output location: set output window, init transform, open - * output map. Both fds now stay open; rd_window/wr_window are set and - * survive env switches, so reads/writes use the right windows throughout. - */ + /* Back in the output env, set the output window, init the transform, and + * open the output map. Both fds stay open and their windows survive env + * switches, so reads and writes use the right window throughout. */ G_switch_env(); Rast_set_output_window(&outcellhd); G_unset_window(); @@ -1098,17 +1082,15 @@ int main(int argc, char **argv) else fdo = Rast_open_fp_new(mapname); - /* Banding (r.neighbors two-level structure): outer serial band loop -> - * serial strip load -> parallel compute into a per-band buffer -> serial - * in-order per-band write -> next band. Bounds peak memory by the cap - * instead of the whole input map (Path A). */ + /* Banding runs a serial band loop of serial strip load, parallel compute + * into a per-band buffer, and an in-order per-band write. This bounds peak + * memory by the cap rather than the whole input map. */ double cap_mb = atof(memory->answer); size_t cap_bytes = (size_t)(cap_mb * 1024.0 * 1024.0); /* Under write_overlap the overlapped writes run inside the compute region, - * so their wall time falls in t_compute. t_write then covers the - * non-overlapped writes only: the last band's flush (timed at - * fallback_done) and every band at N=1. Fallback bail flushes are untimed, - * but a fallback run reports fallback=1 rather than this phase split. */ + * so their time falls in t_compute. t_write then covers only the + * non-overlapped writes, which are the last band's flush and every band at + * one thread. The fallback bail flushes are untimed. */ double t_size = 0.0, t_fill = 0.0, t_compute = 0.0, t_write = 0.0; int n_bands = 0; int max_tiles = 1; /* most column tiles used by any single band */ @@ -1117,20 +1099,11 @@ int main(int argc, char **argv) int seed_h1 = 0; /* previous Phase-1 band's accepted height */ int p1_hits = 0, p1_bands = 0; /* seed hit rate on the Phase-1 path */ - /* Output-row center northings, precomputed once by the serial version's - * recurrence: ycoord2 = north - ns_res/2, then ycoord2 -= ns_res per row. - * The banded fill loop and the strip-sizing perimeter walk both read these - * instead of computing north - ns_res/2 - row*ns_res directly. The direct - * multiply and the accumulated subtraction differ by up to one ULP when - * ns_res is not exactly representable; for non-nearest interpolation that - * shifts the sampling weights and diverges from the serial result by up to - * one FCELL ULP. The recurrence is reproduced here deliberately - * (bug-compatible rounding) so the parallel output stays bitwise identical - * to the serial reference; the direct multiply is the numerically cleaner - * form, so any future change away from the recurrence should be made in - * both code paths as an explicit accuracy decision. Both the fill loop and - * the sizing walk read these values, so sizing and fill stay on the same y - * and the loaded strip covers exactly the rows fill probes. */ + /* Output-row center northings from the serial recurrence, starting at north + * minus ns_res/2 and subtracting ns_res per row. The direct form differs by + * up to one ULP when ns_res is not exactly representable, so the recurrence + * is kept to stay bitwise identical to serial. The fill loop and the sizing + * walk share these values. */ double *y_center = G_malloc((size_t)outcellhd.rows * sizeof(double)); { double yc = outcellhd.north - (outcellhd.ns_res / 2); @@ -1140,20 +1113,16 @@ int main(int argc, char **argv) } } - /* Pole footprint fix: a tile whose interior projects onto a geographic pole - * has an input-row extremum the perimeter walk misses. This happens both - * when the pole lies inside the input map and when the pole is outside the - * input's latitude coverage but its projection still falls inside the - * output frame (a pole-centered frame reading an input truncated below the - * pole): the highest reachable input latitude is then the input's own edge - * row, reached at the frame-center-proximal interior. So project both poles - * (lat/lon input only, where a pole is at latitude +/- 90) and fold in the - * pole's input row clamped to the input's edge row [0, rows-1]. The - * point-in-rect test in band_input_row_span keeps this a no-op for frames - * that do not image a pole. On transform failure or a non-finite result - * (e.g. a cylindrical projection sending the pole to infinity) the pole is - * skipped and the strip under-size guard stays the backstop. Uses the - * adjusted incellhd, matching what band_input_row_span sees. */ + /* Pole footprint fix. A tile that projects onto a geographic pole has an + * input-row extremum the perimeter walk misses. This happens when the pole + * lies inside the input map, and also when the pole is outside the input's + * latitude coverage but still projects into the output frame, as with a + * pole-centered frame reading an input truncated below the pole, where the + * highest reachable input latitude is the input's own edge row. So it + * projects both poles for lat/lon input and folds in the pole's input row + * clamped to [0, rows-1]. The point-in-rect test keeps this a no-op for + * frames that image no pole, and a transform failure or non-finite result + * skips the pole and leaves the under-size guard as the backstop. */ struct pole_set poles; poles.n = 0; @@ -1189,37 +1158,34 @@ int main(int argc, char **argv) unsigned char *win = NULL; size_t win_cap = 0; int win_imin = 0, win_imax = -1; - /* One predicate for output double-buffering. The compute region runs - * want_nprocs threads (omp_get_max_threads(), not the masked read_nprocs), - * so overlap is possible only with more than one compute thread. out_mult - * reserves two output bands in the fit search and the omp single writer - * engages on the same flag, so the budget and the writer cannot diverge. */ + /* Output double-buffer predicate. The compute region runs want_nprocs + * threads rather than the masked read_nprocs, so overlap needs more than + * one compute thread. out_mult reserves two output bands in the fit search + * on the same flag the writer uses, so the budget and the writer stay in + * step. */ int write_overlap = want_nprocs > 1; int out_mult = write_overlap ? 2 : 1; - /* Previous band's output buffer, written by one thread while the next - * band computes. NULL when nothing is pending; rows + /* Previous band's output buffer, written by one thread while the next band + * computes. It is NULL when nothing is pending and holds rows * [pending_r0, pending_r1). */ void *pending_out = NULL; int pending_r0 = 0, pending_r1 = 0; int obr0 = 0; while (obr0 < outcellhd.rows) { - /* Fit search. Phase 1 (fast path, unchanged): halve the band height - * until the FULL-WIDTH strip plus the band output buffer fit the cap; - * the span is re-run per candidate height. Phase 2 (oblique fallback): - * only if a single full-width output row still busts the cap, split the - * row into column tiles and halve tile WIDTH until the worst tile's - * strip fits. Strips are full input width (the raster API reads whole - * rows), so width splitting shrinks a tile's input ROW span, not its + /* Fit search. Phase 1 halves the band height until the full-width strip + * plus output buffer fit the cap. Phase 2 runs only when a single + * full-width row still busts the cap, splitting the row into column + * tiles and halving tile width until the worst tile's strip fits. + * Strips are full input width because the raster API reads whole rows, + * so width splitting shrinks a tile's input row span rather than its * width. Easy pairs never leave Phase 1. */ double ts = rproj_wtime(); - /* Band-0 early-out for the wide-input corner: if a single output row at - * the finest tiling already busts the cap, take the serial fallback now - * instead of running the height/width search only to bail. Uses the - * same worst_tile_strip_rows(obr0, obr0+1, 1) the Phase-2 bail uses, - * probed only at the first band so its O(cols) cost is paid once, not - * per band. Later-band (pole) busts still fall through to the Phase-2 - * bail. force_tilecache is deliberately not handled here, so the forced - * override keeps routing through that bail unchanged. */ + /* Band-0 early-out for the wide-input corner. When a single output row + * at the finest tiling already busts the cap, take the serial fallback + * now instead of running the search only to bail. This is probed once + * at the first band. Later-band pole busts still fall through to the + * Phase-2 bail, and force_tilecache is deliberately not handled here so + * the override keeps routing through that bail. */ if (obr0 == 0) { size_t out1 = (size_t)outcellhd.cols * cell_size; int worst1 = worst_tile_strip_rows(&outcellhd, &incellhd, &oproj, @@ -1251,16 +1217,14 @@ int main(int argc, char **argv) } int tilew = outcellhd.cols; int imin = 0, imax = -1; - /* Phase-1 neighbor seed (hit path): seed_h1 (previous Phase-1 accepted - * height) is close to this band's. Take g_seed, the grid height just - * ABOVE seed_h1 on this band's descending lattice; if it does not fit - * then (height-monotone span) nothing taller fits, so the tallest - * fitting height is at or below (g_seed+1)/2 and the walk can start - * there, skipping the tall full-width edge walks. Any miss (no seed, - * seed_h1 too tall, or g_seed fits) starts from the full remaining - * height -- byte-for-byte the walk below. Same lattice, same acceptance - * line -> identical accepted height and partition; the hit only skips - * heights it has shown cannot fit. */ + /* Phase-1 neighbor seed. seed_h1 is the previous accepted height and is + * close to this band's. Take g_seed, the grid height just above + * seed_h1, and if it does not fit then nothing taller fits, so the walk + * starts at (g_seed+1)/2 and skips the tall full-width edge walks. Any + * miss starts from the full remaining height. The lattice and + * acceptance line are the same, so the accepted height and partition + * are identical and the hit only skips heights already shown not to + * fit. */ int band_orows = outcellhd.rows - obr0; int p1_seeded = 0; if (seed_h1 > 0 && seed_h1 < band_orows) { @@ -1288,7 +1252,7 @@ int main(int argc, char **argv) strip_bytes + out_mult * out_bytes <= cap_bytes) break; if (band_orows == 1) - break; /* height exhausted: fall through to column splitting */ + break; /* height exhausted, fall through to column splitting */ band_orows = (band_orows + 1) / 2; /* halve (round up), re-sample */ } if (band_orows > 1) { /* Phase-1 accepted a full-width band */ @@ -1298,19 +1262,14 @@ int main(int argc, char **argv) p1_hits++; } if (band_orows == 1) { - /* Phase 2 (oblique only): find the tallest band height on the - * descending grid whose worst column tile fits the cap, then that - * height's widest fitting tile width. Neighbor seed (hit path): the - * previous Phase-2 band's height (seed_h) is close to this band's - * H*. Take g_seed, the grid height just ABOVE seed_h; if it does - * not fit then (for an input-row span monotone in band height) - * nothing taller fits, so H* is at or below g_seed and the walk can - * start there, skipping the tall no-fit heights. On a miss (no - * seed, seed_h too tall, or g_seed fits) start from the full - * remaining height -- byte-for-byte the unseeded walk. Both starts - * lie on the same grid and accept via the same phase2_width_fit, so - * H*, W* and the partition are identical; the hit path only skips - * heights it has shown cannot fit. */ + /* Phase 2, oblique only, finds the tallest grid height whose worst + * column tile fits, then that height's widest fitting tile width. + * seed_h is the previous Phase-2 height and is close to H*. Take + * g_seed just above seed_h, and if it does not fit then nothing + * taller fits, so the walk starts at (g_seed+1)/2. A miss starts + * from the full remaining height. The grid and phase2_width_fit + * acceptance are the same, so H*, W*, and the partition are + * identical. */ phase2_bands++; int start_h = outcellhd.rows - obr0; if (seed_w > 0 && seed_h < start_h) { @@ -1334,11 +1293,11 @@ int main(int argc, char **argv) &poles)) break; if (band_orows == 1) { - /* Single output row at minimum width still over cap = - * singular/large-halo; take the serial tile-cache path. - * Also reached from band 0 when R_PROJ_FORCE_TILECACHE is - * set, which routes normal data through this identical - * block for testing. */ + /* A single output row at minimum width still over the cap + * is a singular or large-halo case, so take the serial + * tile-cache path. This is also reached from band 0 when + * R_PROJ_FORCE_TILECACHE routes normal data here for + * testing. */ if (force_tilecache) { G_warning( _("R_PROJ_FORCE_TILECACHE is set: taking the " @@ -1369,8 +1328,8 @@ int main(int argc, char **argv) obr0, outcellhd.rows - 1, needed_mb); } /* Flush the deferred band before the fallback writes from - * obr0 (in-order). This band's compute region did not run, - * so its omp single did not write the previous band. */ + * obr0. This band's compute region did not run, so its + * writer never fired. */ flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, &pending_out, pending_r0, pending_r1); @@ -1393,23 +1352,22 @@ int main(int argc, char **argv) if (n_tiles > max_tiles) max_tiles = n_tiles; - /* Per-band output buffer, lock-free disjoint row slots, filled column - * tile by column tile and written once after all tiles. Full width - * regardless of tiling. */ + /* Per-band output buffer at full width, filled tile by tile and written + * once after all tiles. The row slots are disjoint, so compute is + * lock-free. */ void *band_out = G_malloc((size_t)band_orows * outcellhd.cols * cell_size); - /* Column tiles processed one at a time: only the current tile's strip - * is resident, so peak strip memory is the worst tile, not the band's - * union. tilew == cols is the single-tile fast path (obc0=0, - * obc1=cols), identical to un-tiled banding. */ + /* Column tiles are processed one at a time, so peak strip memory is the + * worst tile rather than the band's union. A tilew equal to cols is the + * single-tile fast path. */ for (int obc0 = 0; obc0 < outcellhd.cols; obc0 += tilew) { int obc1 = obc0 + tilew; if (obc1 > outcellhd.cols) obc1 = outcellhd.cols; - /* Per-tile input row span (full-width strip: the raster API reads - * whole rows, so columns are not cropped). */ + /* Per-tile input row span. The strip is full input width because + * the raster API reads whole rows, so columns are not cropped. */ int pole_widened = 0; band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, @@ -1422,10 +1380,11 @@ int main(int argc, char **argv) (int)poles.ri[pole_widened - 1], obr0, obr1, obc0, obc1); int strip_rows = imax - imin + 1; - /* Serial strip load (single fd -> get_row not thread-safe). EMPTY - * TILE: strip_rows <= 0 -> projects outside input, no read; cells - * become NULL via interpolate_strip's out-of-map path, and the - * window is invalidated so the next band re-reads in full. */ + /* Serial strip load, since a single fd makes get_row unsafe to + * share. An empty tile with strip_rows at or below zero projects + * outside the input and is not read, its cells become NULL through + * interpolate_strip's out-of-map path, and the window is + * invalidated so the next band re-reads in full. */ void *strip = NULL; if (strip_rows > 0) { size_t need = (size_t)strip_rows * incellhd.cols * cell_size; @@ -1437,13 +1396,12 @@ int main(int argc, char **argv) win_imax >= 0 && imin >= win_imin && imin <= win_imax + 1; int read_from = imin; - /* Grow first, then memmove, then read the tail. G_realloc may - * move the buffer, so it must run before the memmove that + /* Grow, then memmove, then read the tail. G_realloc may move + * the buffer, so it must run before the memmove that * repositions the retained overlap. Realloc preserves the old - * rows at their old offsets, and the memmove shifts them to the - * new imin origin. If the span shrinks (imax < win_imax), the - * rows past imax are dropped from win_imax below rather than - * kept, so a later band that needs them re-reads them. */ + * rows at their old offsets and the memmove shifts them to the + * new imin origin. Rows past a shrunk imax are dropped through + * win_imax below and re-read if a later band needs them. */ if (need > win_cap) { win = G_realloc(win, need); win_cap = need; @@ -1459,10 +1417,11 @@ int main(int argc, char **argv) G_switch_env(); /* -> input */ if (read_nprocs > 1) { #ifdef _OPENMP - /* Parallel read (full-span only; can_slide is false - * here): each thread reads a contiguous, disjoint block - * of rows through its OWN fd into its own disjoint - * strip slice. No two threads share an fd/row. */ + /* Parallel read of the full span only, since can_slide + * is false here. Each thread reads a contiguous + * disjoint block of rows through its own fd into its + * own strip slice, so no two threads share an fd or a + * row. */ #pragma omp parallel num_threads(read_nprocs) { int t = omp_get_thread_num(); @@ -1491,7 +1450,7 @@ int main(int argc, char **argv) /* Record what the window now holds. A tiled band leaves win * with only its last tile, so invalidate both fields to force * the next band's full read. Setting both keeps validity - * independent of && short-circuit order. */ + * independent of the && short-circuit order. */ if (n_tiles == 1) { win_imin = imin; win_imax = imax; @@ -1503,13 +1462,13 @@ int main(int argc, char **argv) } else { win_imin = 0; - win_imax = -1; /* empty tile: nothing resident */ + win_imax = -1; /* empty tile, nothing resident */ } double t1 = rproj_wtime(); - /* One parallel region per tile. Not nested: the "omp for" divides - * the band's output rows among this region's threads. Separate - * directives so each thread clones its PROJ context before the row + /* One parallel region per tile. The omp for divides the band's + * output rows among this region's threads. The directives are + * separate so each thread clones its PROJ context before the row * loop and destroys it after. */ #pragma omp parallel { @@ -1519,8 +1478,8 @@ int main(int argc, char **argv) #pragma omp single nowait { /* One thread writes the previous band's rows in order while - * the rest compute this band. First tile only, and - * pending_out is non-NULL only under write_overlap. */ + * the rest compute this band. This is the first tile only, + * and pending_out is non-NULL only under write_overlap. */ if (obc0 == 0 && pending_out) for (int wr = pending_r0; wr < pending_r1; wr++) Rast_put_row(fdo, @@ -1567,10 +1526,10 @@ int main(int argc, char **argv) * freed per tile. win is freed once at fallback_done. */ } - /* Defer this band so the next band's compute region writes it (via the - * omp single above). The previous pending was written in this band's - * compute region and completed at that region's barrier, so free it - * now. Non-overlap bands write and free in order here. */ + /* Defer this band so the next band's compute region writes it through + * the omp single above. The previous pending completed at this band's + * compute barrier, so free it now. Non-overlap bands write and free in + * order here. */ if (write_overlap) { if (pending_out) G_free(pending_out); @@ -1596,8 +1555,8 @@ int main(int argc, char **argv) fallback_done: /* Flush the last band's deferred write on normal completion, timed into - * t_write. The fallback bails flush before fallback_serial_cache, so - * pending_out is NULL here on those paths. */ + * t_write. The fallback bails already flushed, so pending_out is NULL on + * those paths. */ { double tw = rproj_wtime(); flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, @@ -1605,11 +1564,9 @@ int main(int argc, char **argv) t_write += rproj_wtime() - tw; } G_free(y_center); - /* Single free site for the rolling window: the band loop's only exits are - * normal completion (falls through to here) and the two goto fallback_done - * bails (band-0 early-out, Phase-2 width bust), all converging on this - * label, so one free covers every path crossing the window's live range. - * win is NULL if a bail fired before any band allocated it. */ + /* Single free site for the rolling window. Normal completion and both + * fallback_done bails converge here, so one free covers every path. win is + * NULL when a bail fired before any band allocated it. */ if (win) G_free(win); diff --git a/raster/r.proj/tests/r_proj_parallel_test.py b/raster/r.proj/tests/r_proj_parallel_test.py index 573d53331ef..02b4dc89630 100644 --- a/raster/r.proj/tests/r_proj_parallel_test.py +++ b/raster/r.proj/tests/r_proj_parallel_test.py @@ -1,22 +1,22 @@ """Parallel-correctness tests for r.proj. -r.proj has no nprocs= option; its thread count comes from OMP_NUM_THREADS. -Every run below is given its OWN environment dict, a copy of the session -env with OMP_NUM_THREADS (and, for the fallback test, R_PROJ_FORCE_TILECACHE) -set on the copy for that run only. Nothing shared is mutated, so the serial -and parallel runs of a test cannot leak thread or path state into each other. - -The baseline is the module's own single-thread run (OMP_NUM_THREADS=1), not -an external serial binary. The question these tests answer is whether adding +r.proj takes a nprocs= option that sets the compute thread count, so each run +below passes nprocs= for that run, and the fallback test also sets +R_PROJ_FORCE_TILECACHE on its own env copy. Nothing shared is mutated, so the +serial and parallel runs of a test cannot leak thread or path state into each +other. + +The baseline is the module's own single-thread run at nprocs=1 rather than an +external serial binary. The question these tests answer is whether adding threads, or taking the tile-cache fallback, changes the output of this same -binary. That comparison is exact and reproducible in CI; an external oracle -would not be. - -Correctness rule: nearest is asserted bitwise (abs diff max == 0). Bilinear -is asserted bitwise too, because each output cell is interpolated -independently in a fixed operation order, so threading does not reorder its -arithmetic. The epsilon-1e-6 fallback from the proposal may be invoked only -on an actual CI reordering failure, naming the platform that showed it. +binary. That comparison is exact and reproducible in CI where an external +oracle would not be. + +Nearest is asserted bitwise with an absolute diff max of zero. Bilinear is +asserted bitwise too because each output cell is interpolated independently in +a fixed operation order, so threading does not reorder its arithmetic. The +epsilon-1e-6 fallback from the proposal may be invoked only on an actual CI +reordering failure, naming the platform that showed it. """ import grass.script as gs @@ -106,8 +106,8 @@ def test_bilinear_parallel_matches_serial(session_3857): base = _env(session) _set_region_from_source(base, INPUT_MID, "bilinear") - _project(_env(session, OMP_NUM_THREADS=1), INPUT_MID, "bilin_serial", "bilinear") - _project(_env(session, OMP_NUM_THREADS=1), INPUT_MID, "nearest_ref", "nearest") + _project(base, INPUT_MID, "bilin_serial", "bilinear", nprocs=1) + _project(base, INPUT_MID, "nearest_ref", "nearest", nprocs=1) gs.run_command( "r.mapcalc", expression="dispatch_live = abs(bilin_serial - nearest_ref)", @@ -118,7 +118,7 @@ def test_bilinear_parallel_matches_serial(session_3857): "bilinear output equals nearest; dispatch may have fallen back" ) - _project(_env(session, OMP_NUM_THREADS=4), INPUT_MID, "bilin_parallel", "bilinear") + _project(base, INPUT_MID, "bilin_parallel", "bilinear", nprocs=4) _assert_bitwise_identical(base, "bilin_serial", "bilin_parallel", "bilin_diff") @@ -129,10 +129,8 @@ def test_nearest_memory_banding(session_3857): base = _env(session) _set_region_from_source(base, INPUT_MID, "nearest") - _project(_env(session, OMP_NUM_THREADS=1), INPUT_MID, "mem_serial", "nearest") - _project( - _env(session, OMP_NUM_THREADS=4), INPUT_MID, "mem_banded", "nearest", memory=5 - ) + _project(base, INPUT_MID, "mem_serial", "nearest", nprocs=1) + _project(base, INPUT_MID, "mem_banded", "nearest", nprocs=4, memory=5) _assert_bitwise_identical(base, "mem_serial", "mem_banded", "mem_diff") @@ -153,8 +151,8 @@ def test_pole_nearest_parallel_matches_serial(session_pole): env=base, ) - _project(_env(session, OMP_NUM_THREADS=1), INPUT_POLAR, "pole_serial", "nearest") - _project(_env(session, OMP_NUM_THREADS=4), INPUT_POLAR, "pole_parallel", "nearest") + _project(base, INPUT_POLAR, "pole_serial", "nearest", nprocs=1) + _project(base, INPUT_POLAR, "pole_parallel", "nearest", nprocs=4) _assert_bitwise_identical(base, "pole_serial", "pole_parallel", "pole_diff") @@ -168,10 +166,11 @@ def test_forced_fallback_matches_banded(session_3857): _set_region_from_source(base, INPUT_MID, "nearest") _project( - _env(session, OMP_NUM_THREADS=1, R_PROJ_FORCE_TILECACHE=1), + _env(session, R_PROJ_FORCE_TILECACHE=1), INPUT_MID, "fallback_tilecache", "nearest", + nprocs=1, ) - _project(_env(session, OMP_NUM_THREADS=4), INPUT_MID, "banded", "nearest") + _project(base, INPUT_MID, "banded", "nearest", nprocs=4) _assert_bitwise_identical(base, "fallback_tilecache", "banded", "fallback_diff") From 003e7ab3249ed96142a3f974ca7781d1963aae24 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 22 Jul 2026 20:58:26 -0700 Subject: [PATCH 23/32] r.proj: add thread scaling benchmark script 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. --- raster/r.proj/benchmark/benchmark_r_proj.py | 135 ++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 raster/r.proj/benchmark/benchmark_r_proj.py diff --git a/raster/r.proj/benchmark/benchmark_r_proj.py b/raster/r.proj/benchmark/benchmark_r_proj.py new file mode 100644 index 00000000000..acbd77abb6e --- /dev/null +++ b/raster/r.proj/benchmark/benchmark_r_proj.py @@ -0,0 +1,135 @@ +"""Benchmarking of r.proj thread scaling +raster (2D) + +This follows the r.param.scale benchmark structure, sweeping raster size at a +fixed memory and then memory at a fixed raster size, and plotting the time, +speedup, and efficiency metrics with grass.benchmark. r.proj sweeps its compute +thread count through the nprocs= option. Each cell generates a source raster in +an EPSG:4326 project and reprojects it into EPSG:3857 in a temporary database, +so the script is self-contained. Run it with +grass --exec python benchmark_r_proj.py or from any GRASS session. +""" + +import os +import tempfile + +from grass.exceptions import CalledModuleError, GrassError +from grass.pygrass.modules import Module +import grass.script as gs +import grass.benchmark as bm + +# Baselines held fixed while one dimension is swept. +BASE_MAPSIZE = 50e6 # cells +BASE_MEMORY = 300 # MB +MAPSIZES = [10e6, 50e6, 100e6] +MEMORIES = [50, 100, 300, 1000] +METRICS = ["time", "speedup", "efficiency"] +MAX_NPROCS = 8 +REPEAT = 3 + +SRC_PROJECT = "src4326" +DST_PROJECT = "dst3857" +INPUT = "benchmark_r_proj_reference" +OUTPUT = "benchmark_r_proj" + + +def main(): + gisdbase = tempfile.mkdtemp(prefix="bench_r_proj_") + gs.create_project(os.path.join(gisdbase, SRC_PROJECT), epsg="4326") + gs.create_project(os.path.join(gisdbase, DST_PROJECT), epsg="3857") + + # Sweep raster size at the baseline memory. + results = [] + for mapsize in MAPSIZES: + benchmark( + gisdbase, + size=int(mapsize**0.5), + memory=BASE_MEMORY, + label=f"r.proj_{int(mapsize / 1e6)}M", + results=results, + ) + plot(results, "rastersize") + + # Sweep memory at the baseline raster size. + results = [] + for memory in MEMORIES: + benchmark( + gisdbase, + size=int(BASE_MAPSIZE**0.5), + memory=memory, + label=f"r.proj_memory_{memory}MB", + results=results, + ) + plot(results, "memory") + + +def benchmark(gisdbase, size, memory, label, results): + generate_input(gisdbase, size) + with gs.setup.init( + os.path.join(gisdbase, DST_PROJECT), env=os.environ.copy() + ) as session: + env = session.env + # Output region from r.proj's own suggested bounds for this input. + text = gs.read_command( + "r.proj", + project=SRC_PROJECT, + mapset="PERMANENT", + dbase=gisdbase, + input=INPUT, + method="nearest", + flags="g", + env=env, + ) + region = dict(token.split("=") for token in text.split()) + gs.run_command("g.region", env=env, **region) + + module = Module( + "r.proj", + project=SRC_PROJECT, + mapset="PERMANENT", + dbase=gisdbase, + input=INPUT, + output=OUTPUT, + method="nearest", + memory=memory, + env_=env, + run_=False, + overwrite=True, + ) + results.append( + bm.benchmark_nprocs( + module, label=label, max_nprocs=MAX_NPROCS, repeat=REPEAT + ) + ) + + +def generate_input(gisdbase, size): + """Generate the size by size source raster in the EPSG:4326 project, + mirroring the r.param.scale benchmark by trying r.surf.fractal and falling + back to r.random.surface when fractal is unavailable, for example in a build + without FFTW.""" + with gs.setup.init( + os.path.join(gisdbase, SRC_PROJECT), env=os.environ.copy() + ) as session: + env = session.env + gs.run_command( + "g.region", n=50, s=40, w=-110, e=-90, rows=size, cols=size, env=env + ) + try: + Module("r.surf.fractal", output=INPUT, overwrite=True, env_=env) + except (CalledModuleError, GrassError): + Module("r.random.surface", output=INPUT, overwrite=True, env_=env) + + +def plot(results, sweep): + for metric in METRICS: + bm.nprocs_plot( + results, + filename=f"r_proj_{sweep}_{metric}.svg", + title=f"r.proj {sweep} {metric}", + metric=metric, + ) + + +if __name__ == "__main__": + main() From 9377a2e325d99fc5369b2332786627a35f4efea4 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Fri, 31 Jul 2026 22:47:09 -0700 Subject: [PATCH 24/32] r.proj: add footprint grid measurement alongside the fit search Add a footprint grid of input row spans, one grid row per output row and thirty-two column blocks wide, in a boundary-sampled and a column-exact variant. The grid only observes in this commit. The live fit search still steers every band height and tile width, and the grid span is compared against the search span for each rectangle the search evaluates. The comparison is gated by the R_PROJ_FG_VERIFY environment variable, so a normal run builds no grid and prints nothing. Each boundary cell carries a one row sampling margin, since the column samples can miss a curve between them by a fraction of a row. The margin is applied after the two variants are compared, so the variant report still measures the raw sampling error. --- raster/r.proj/footprint.c | 271 ++++++++++++++++++++++++++++++++++++++ raster/r.proj/main.c | 84 ++++++++++-- raster/r.proj/r.proj.h | 24 ++++ 3 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 raster/r.proj/footprint.c diff --git a/raster/r.proj/footprint.c b/raster/r.proj/footprint.c new file mode 100644 index 00000000000..2ee92fb531a --- /dev/null +++ b/raster/r.proj/footprint.c @@ -0,0 +1,271 @@ +/* + * footprint.c - grid of input row spans for the output map. + * + * Each cell covers one output row and one column block and holds the range of + * input rows that block reaches. + */ + +#include +#include +#include + +#include +#include + +#include "r.proj.h" + +struct fg_cell { + double rmin, rmax; /* rmax below rmin marks an empty cell */ +}; + +struct footprint_grid { + int variant; /* FG_BOUNDARY or FG_EXACT */ + int grows, nb; /* grid rows and column blocks */ + int ocols; /* output columns */ + int irows; /* input rows */ + struct fg_cell *cell; /* grows by nb cells in row major order */ +}; + +/* The samples can miss a curve between columns by a fraction of a row, so each + * cell is widened by one row. */ +#define FG_SAMPLING_MARGIN 1.0 + +/* Returns the first output column of block b. */ +static int block_c0(const struct footprint_grid *g, int b) +{ + return (int)((long)b * g->ocols / g->nb); +} + +/* Returns the block that contains output column c. */ +static int block_of_col(const struct footprint_grid *g, int c) +{ + int b; + + for (b = 0; b < g->nb - 1; b++) + if (c < block_c0(g, b + 1)) + return b; + return g->nb - 1; +} + +/* Projects the center of output cell (r, c) to an input row index. Returns 0 on + * a failed transform and leaves ri unchanged. */ +static int sample_ri(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, const double *y_center, int r, + int c, double *ri) +{ + double xx = ohd->west + (c + 0.5) * ohd->ew_res; + double yy = y_center[r]; + + if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &xx, &yy, NULL) < 0) + return 0; + *ri = (ihd->north - yy) / ihd->ns_res; + return 1; +} + +/* Widens cell (r, b) to include any pole whose output point falls inside the + * cell rectangle. */ +static void fold_poles(const struct footprint_grid *g, + const struct Cell_head *ohd, + const struct pole_set *poles, int r, int b, + struct fg_cell *cell) +{ + int c0 = block_c0(g, b), c1 = block_c0(g, b + 1), k; + double x_lo = ohd->west + c0 * ohd->ew_res; + double x_hi = ohd->west + c1 * ohd->ew_res; + double y_lo = ohd->north - (r + 1) * ohd->ns_res; + double y_hi = ohd->north - r * ohd->ns_res; + + if (!poles) + return; + for (k = 0; k < poles->n; k++) { + if (poles->ox[k] < x_lo || poles->ox[k] > x_hi || poles->oy[k] < y_lo || + poles->oy[k] > y_hi) + continue; + if (poles->ri[k] < cell->rmin) + cell->rmin = poles->ri[k]; + if (poles->ri[k] > cell->rmax) + cell->rmax = poles->ri[k]; + } +} + +/* Builds the grid using boundary samples or every column. */ +struct footprint_grid * +fg_build(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, const double *y_center, + const struct pole_set *poles, int variant) +{ + struct footprint_grid *g = G_malloc(sizeof(*g)); + int r, b; + double *bnd = NULL; + + g->variant = variant; + g->grows = ohd->rows; + g->nb = ohd->cols < 32 ? ohd->cols : 32; + g->ocols = ohd->cols; + g->irows = ihd->rows; + g->cell = G_malloc((size_t)g->grows * g->nb * sizeof(struct fg_cell)); + + if (variant == FG_BOUNDARY) + bnd = G_malloc((size_t)(g->nb + 1) * sizeof(double)); + + for (r = 0; r < g->grows; r++) { + if (variant == FG_BOUNDARY) { + /* Sample the NB plus one block boundaries for this row. The last + * boundary uses the final valid column. */ + int k; + + for (k = 0; k <= g->nb; k++) { + int c = block_c0(g, k); + + if (c > g->ocols - 1) + c = g->ocols - 1; + if (!sample_ri(ohd, ihd, oproj, iproj, tproj, y_center, r, c, + &bnd[k])) + bnd[k] = + DBL_MAX; /* a failed sample is left out of the range */ + } + } + for (b = 0; b < g->nb; b++) { + struct fg_cell *cell = &g->cell[(size_t)r * g->nb + b]; + + cell->rmin = DBL_MAX; + cell->rmax = -DBL_MAX; + if (variant == FG_BOUNDARY) { + double lo = bnd[b] < bnd[b + 1] ? bnd[b] : bnd[b + 1]; + double hi = bnd[b] > bnd[b + 1] ? bnd[b] : bnd[b + 1]; + + if (bnd[b] != DBL_MAX && bnd[b + 1] != DBL_MAX) { + cell->rmin = lo; + cell->rmax = hi; + } + else if (bnd[b] != DBL_MAX) { + cell->rmin = cell->rmax = bnd[b]; + } + else if (bnd[b + 1] != DBL_MAX) { + cell->rmin = cell->rmax = bnd[b + 1]; + } + } + else { + /* Scan every column in the block. */ + int c0 = block_c0(g, b), c1 = block_c0(g, b + 1), c; + + for (c = c0; c < c1; c++) { + double ri; + + if (!sample_ri(ohd, ihd, oproj, iproj, tproj, y_center, r, + c, &ri)) + continue; + if (ri < cell->rmin) + cell->rmin = ri; + if (ri > cell->rmax) + cell->rmax = ri; + } + } + fold_poles(g, ohd, poles, r, b, cell); + } + } + if (bnd) + G_free(bnd); + return g; +} + +/* Returns the input row span covering the output rectangle. Includes every + * block the rectangle touches and adds a two cell margin. The grid holds one + * row per output row, so every output row in the rectangle indexes a grid row. + */ +void fg_span(const struct footprint_grid *g, int obr0, int obr1, int obc0, + int obc1, int *imin, int *imax) +{ + double rmin = DBL_MAX, rmax = -DBL_MAX; + int b_lo = block_of_col(g, obc0), b_hi = block_of_col(g, obc1 - 1); + int r, b; + + if (obr1 > g->grows) + G_fatal_error(_("Footprint grid has %d rows but output row %d was " + "requested"), + g->grows, obr1 - 1); + + for (r = obr0; r < obr1; r++) + for (b = b_lo; b <= b_hi; b++) { + const struct fg_cell *cell = &g->cell[(size_t)r * g->nb + b]; + + if (cell->rmax < cell->rmin) + continue; /* empty cell */ + if (cell->rmin < rmin) + rmin = cell->rmin; + if (cell->rmax > rmax) + rmax = cell->rmax; + } + + if (rmax < rmin) { /* every touched cell empty */ + *imin = 0; + *imax = -1; + return; + } + int lo = (int)floor(rmin) - 2; + int hi = (int)floor(rmax) + 2; + + if (lo < 0) + lo = 0; + if (hi > g->irows - 1) + hi = g->irows - 1; + *imin = lo; + *imax = hi; +} + +/* Reports how many cells the exact variant makes wider than the boundary + * variant, with the largest widening on each side. */ +void fg_compare_variants(const struct footprint_grid *b, + const struct footprint_grid *e) +{ + size_t n = (size_t)b->grows * b->nb, i; + long differ = 0; + double max_lo_gap = 0.0, max_hi_gap = 0.0; + + for (i = 0; i < n; i++) { + const struct fg_cell *cb = &b->cell[i], *ce = &e->cell[i]; + double lo_gap, hi_gap; + + if (cb->rmax < cb->rmin && ce->rmax < ce->rmin) + continue; + lo_gap = cb->rmin - ce->rmin; /* exact reaches this much lower */ + hi_gap = ce->rmax - cb->rmax; /* exact reaches this much higher */ + if (lo_gap > 0.0 || hi_gap > 0.0) { + differ++; + if (lo_gap > max_lo_gap) + max_lo_gap = lo_gap; + if (hi_gap > max_hi_gap) + max_hi_gap = hi_gap; + } + } + fprintf(stderr, + "FG_VAR cells=%ld differ=%ld max_lo_gap=%.3f max_hi_gap=%.3f\n", + (long)n, differ, max_lo_gap, max_hi_gap); +} + +/* Widens every non-empty cell of a boundary grid by the sampling margin. */ +void fg_apply_sampling_margin(struct footprint_grid *g) +{ + size_t n = (size_t)g->grows * g->nb, i; + + if (g->variant != FG_BOUNDARY) + return; + for (i = 0; i < n; i++) { + struct fg_cell *cell = &g->cell[i]; + + if (cell->rmax >= cell->rmin) { + cell->rmin -= FG_SAMPLING_MARGIN; + cell->rmax += FG_SAMPLING_MARGIN; + } + } +} + +void fg_free(struct footprint_grid *g) +{ + if (!g) + return; + G_free(g->cell); + G_free(g); +} diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index eecb031845b..ca16e125ef2 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -133,18 +133,59 @@ static const strip_func strip_kernels[] = { interpolate_strip, strip_bilinear, strip_cubic, strip_lanczos, strip_bilinear_f, strip_cubic_f, strip_lanczos_f}; -/* Geographic poles inside the input's latitude coverage. band_input_row_span - * walks only the tile perimeter, so a pole in a tile's interior is a latitude - * extremum the walk misses, and the pole's row is folded into that tile's span. - * Each pole is stored as its output-CRS coordinate and its input row. The set - * is empty when no pole is in frame. This assumes a pole maps to one output - * point as in azimuthal and stereographic projections, and otherwise the - * under-size guard stays the backstop. */ -struct pole_set { - int n; /* active poles, 0..2 */ - double ox[2], oy[2]; /* pole coordinates in the output CRS */ - double ri[2]; /* pole input row index */ -}; +/* Footprint grid used for comparison and the counters printed at the end. */ +static struct footprint_grid *g_fg_boundary = NULL; +static int g_fg_verify = 0; +static long g_fg_ncmp = 0; /* comparisons made */ +static long g_fg_fail = 0; /* cover failures */ +static int g_fg_min_slack = 0; /* smallest margin between grid and search */ +static int g_fg_max_overread = + 0; /* most extra input rows the grid would load */ +static int g_fg_have_stats = 0; /* set once a non-empty span is compared */ + +/* Compares one search span against the grid span and records the result. Prints + * a line only when the grid fails to cover the search. */ +static void fg_verify_emit(int obr0, int obr1, int obc0, int obc1, int s_imin, + int s_imax) +{ + int g_imin, g_imax, cover, low_slack, high_slack, slack, overread; + + if (!g_fg_verify) + return; + fg_span(g_fg_boundary, obr0, obr1, obc0, obc1, &g_imin, &g_imax); + g_fg_ncmp++; + if (s_imax < s_imin) /* empty search span is always covered */ + return; + cover = g_imin <= s_imin && g_imax >= s_imax; + low_slack = s_imin - g_imin; + high_slack = g_imax - s_imax; + slack = low_slack < high_slack ? low_slack : high_slack; + overread = (g_imax - g_imin) - (s_imax - s_imin); + if (!g_fg_have_stats || slack < g_fg_min_slack) + g_fg_min_slack = slack; + if (overread > g_fg_max_overread) + g_fg_max_overread = overread; + g_fg_have_stats = 1; + if (!cover) { + g_fg_fail++; + fprintf( + stderr, + "FG_CMP r[%d,%d) c[%d,%d) search=[%d,%d] grid=[%d,%d] cover=0\n", + obr0, obr1, obc0, obc1, s_imin, s_imax, g_imin, g_imax); + } +} + +/* Prints one line with the totals from all comparisons. */ +static void fg_verify_summary(void) +{ + if (!g_fg_verify) + return; + fprintf( + stderr, + "FG_SUM comparisons=%ld cover_fail=%ld min_slack=%d max_overread=%d\n", + g_fg_ncmp, g_fg_fail, g_fg_have_stats ? g_fg_min_slack : 0, + g_fg_max_overread); +} /* Edge-walk of an output tile [obr0, obr1) by [obc0, obc1) projected into input * space, returning the min and max input row it touches plus a 2-cell margin, @@ -224,6 +265,7 @@ band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, if (rmax < rmin) { /* band projects entirely outside the input */ *imin = 0; *imax = -1; + fg_verify_emit(obr0, obr1, obc0, obc1, *imin, *imax); return; } @@ -235,6 +277,7 @@ band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, hi = ihd->rows - 1; *imin = lo; *imax = hi; + fg_verify_emit(obr0, obr1, obc0, obc1, *imin, *imax); } /* Largest input-row strip among the width-tilew column tiles that partition the @@ -1148,6 +1191,18 @@ int main(int argc, char **argv) } } + /* Build both grids and compare them when R_PROJ_FG_VERIFY is set. */ + struct footprint_grid *fg_exact = NULL; + if (getenv("R_PROJ_FG_VERIFY")) { + g_fg_boundary = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + y_center, &poles, FG_BOUNDARY); + fg_exact = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + y_center, &poles, FG_EXACT); + fg_compare_variants(g_fg_boundary, fg_exact); + fg_apply_sampling_margin(g_fg_boundary); + g_fg_verify = 1; + } + G_important_message(_("Projecting (banded, per-thread PROJ context)...")); int used_fallback = 0; /* set when the serial tile-cache fallback runs */ @@ -1564,6 +1619,11 @@ int main(int argc, char **argv) t_write += rproj_wtime() - tw; } G_free(y_center); + fg_verify_summary(); + if (g_fg_boundary) + fg_free(g_fg_boundary); + if (fg_exact) + fg_free(fg_exact); /* Single free site for the rolling window. Normal completion and both * fallback_done bails converge here, so one free covers every path. win is * NULL when a bail fired before any band allocated it. */ diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index 28e46a503bb..6a2216ce6a8 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -41,6 +41,30 @@ struct menu { enum OutputFormat { PLAIN, SHELL, JSON }; +/* Geographic poles that fall inside the input map, folded into the input row + * span of the tile that contains them. Empty when no pole is in frame. */ +struct pole_set { + int n; /* active poles, 0 to 2 */ + double ox[2], oy[2]; /* pole coordinates in the output CRS */ + double ri[2]; /* pole input row index */ +}; + +/* Footprint grid of input row spans for the output map, built in footprint.c. + */ +enum fg_variant { FG_BOUNDARY, FG_EXACT }; +struct footprint_grid; +extern struct footprint_grid * +fg_build(const struct Cell_head *ohd, const struct Cell_head *ihd, + const struct pj_info *oproj, const struct pj_info *iproj, + const struct pj_info *tproj, const double *y_center, + const struct pole_set *poles, int variant); +extern void fg_span(const struct footprint_grid *g, int obr0, int obr1, + int obc0, int obc1, int *imin, int *imax); +extern void fg_compare_variants(const struct footprint_grid *b, + const struct footprint_grid *e); +extern void fg_apply_sampling_margin(struct footprint_grid *g); +extern void fg_free(struct footprint_grid *g); + extern void bordwalk(const struct Cell_head *, struct Cell_head *, const struct pj_info *, const struct pj_info *, const struct pj_info *, int); From 3b50ad9c52affd37da0e7b9d7f23c5c41de9c5d0 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Sat, 1 Aug 2026 00:29:33 -0700 Subject: [PATCH 25/32] r.proj: size band heights from the footprint grid Band heights now come from a scan of the footprint grid instead of the Phase-1 halving search, which is removed along with its seed machinery. Phase 2 and the rest of the pipeline are unchanged, and the output stays bitwise identical to the serial reference. --- raster/r.proj/footprint.c | 45 ++++++++++++++ raster/r.proj/main.c | 126 +++++++++++++------------------------- raster/r.proj/r.proj.h | 3 + 3 files changed, 92 insertions(+), 82 deletions(-) diff --git a/raster/r.proj/footprint.c b/raster/r.proj/footprint.c index 2ee92fb531a..26e3020a2cf 100644 --- a/raster/r.proj/footprint.c +++ b/raster/r.proj/footprint.c @@ -215,6 +215,51 @@ void fg_span(const struct footprint_grid *g, int obr0, int obr1, int obc0, *imax = hi; } +/* Find the tallest band at obr0 whose strip and output still fit the cap, and + * never return less than one row. */ +int fg_band_height(const struct footprint_grid *g, int obr0, size_t cap_bytes, + int out_mult, int cell_size, int in_cols) +{ + double rmin = DBL_MAX, rmax = -DBL_MAX; + int max_h = g->grows - obr0, accepted = 1, h, b; + + for (h = 0; h < max_h; h++) { + int r = obr0 + h, strip_rows; + size_t strip_bytes, out_bytes; + + for (b = 0; b < g->nb; b++) { + const struct fg_cell *cell = &g->cell[(size_t)r * g->nb + b]; + + if (cell->rmax < cell->rmin) + continue; + if (cell->rmin < rmin) + rmin = cell->rmin; + if (cell->rmax > rmax) + rmax = cell->rmax; + } + if (rmax < rmin) { + strip_rows = 0; + } + else { + int lo = (int)floor(rmin) - 2; + int hi = (int)floor(rmax) + 2; + + if (lo < 0) + lo = 0; + if (hi > g->irows - 1) + hi = g->irows - 1; + strip_rows = hi - lo + 1; + } + strip_bytes = + strip_rows > 0 ? (size_t)strip_rows * in_cols * cell_size : 0; + out_bytes = (size_t)(h + 1) * g->ocols * cell_size; + if (!(strip_bytes + out_mult * out_bytes <= cap_bytes)) + break; + accepted = h + 1; + } + return accepted; +} + /* Reports how many cells the exact variant makes wider than the boundary * variant, with the largest widening on each side. */ void fg_compare_variants(const struct footprint_grid *b, diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index ca16e125ef2..4da5bde5975 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -142,6 +142,8 @@ static int g_fg_min_slack = 0; /* smallest margin between grid and search */ static int g_fg_max_overread = 0; /* most extra input rows the grid would load */ static int g_fg_have_stats = 0; /* set once a non-empty span is compared */ +static long g_fg_band_audit_fail = + 0; /* bands whose grid strip came out smaller than the walk */ /* Compares one search span against the grid span and records the result. Prints * a line only when the grid fails to cover the search. */ @@ -180,11 +182,11 @@ static void fg_verify_summary(void) { if (!g_fg_verify) return; - fprintf( - stderr, - "FG_SUM comparisons=%ld cover_fail=%ld min_slack=%d max_overread=%d\n", - g_fg_ncmp, g_fg_fail, g_fg_have_stats ? g_fg_min_slack : 0, - g_fg_max_overread); + fprintf(stderr, + "FG_SUM comparisons=%ld cover_fail=%ld min_slack=%d " + "max_overread=%d fg_band_audit_fail=%ld\n", + g_fg_ncmp, g_fg_fail, g_fg_have_stats ? g_fg_min_slack : 0, + g_fg_max_overread, g_fg_band_audit_fail); } /* Edge-walk of an output tile [obr0, obr1) by [obc0, obc1) projected into input @@ -397,28 +399,6 @@ phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, return 0; } -/* Phase-1 fit test that returns 1 when a band of height h at obr0 fits its - * full-width input strip plus output buffer inside the cap. It short-circuits - * on the output buffer alone and is used only by the seed peek. */ -static int phase1_fits(const struct Cell_head *ohd, const struct Cell_head *ihd, - const struct pj_info *oproj, const struct pj_info *iproj, - const struct pj_info *tproj, const double *y_center, - int obr0, int h, size_t cap_bytes, int cell_size, - int out_mult, const struct pole_set *poles) -{ - int imin, imax, strip_rows; - size_t out_bytes = (size_t)h * ohd->cols * cell_size, strip_bytes; - - if (out_mult * out_bytes > cap_bytes) - return 0; - band_input_row_span(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr0 + h, - 0, ohd->cols, &imin, &imax, poles, NULL); - strip_rows = imax - imin + 1; - strip_bytes = - strip_rows > 0 ? (size_t)strip_rows * ihd->cols * cell_size : 0; - return strip_bytes + out_mult * out_bytes <= cap_bytes; -} - /* Serial tile-cache fallback for the oblique and large-halo corner. When even * one output row's full-width strip busts the cap, this finishes the run from * row obr0 with the classic readcell cache and CVAL kernels, exactly as serial @@ -1139,8 +1119,6 @@ int main(int argc, char **argv) int max_tiles = 1; /* most column tiles used by any single band */ int seed_h = 0, seed_w = 0; /* previous Phase-2 band's accepted sizing */ int seed_hits = 0, phase2_bands = 0; /* seed hit rate on the Phase-2 path */ - int seed_h1 = 0; /* previous Phase-1 band's accepted height */ - int p1_hits = 0, p1_bands = 0; /* seed hit rate on the Phase-1 path */ /* Output-row center northings from the serial recurrence, starting at north * minus ns_res/2 and subtracting ns_res per row. The direct form differs by @@ -1191,17 +1169,23 @@ int main(int argc, char **argv) } } - /* Build both grids and compare them when R_PROJ_FG_VERIFY is set. */ + /* Build the grid that sizes band heights. */ + g_fg_boundary = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + y_center, &poles, FG_BOUNDARY); + /* Under the verify flag build the exact grid and compare it before the + * margin is added. */ + int fg_verify_env = getenv("R_PROJ_FG_VERIFY") != NULL; struct footprint_grid *fg_exact = NULL; - if (getenv("R_PROJ_FG_VERIFY")) { - g_fg_boundary = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, &poles, FG_BOUNDARY); + if (fg_verify_env) { fg_exact = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, y_center, &poles, FG_EXACT); fg_compare_variants(g_fg_boundary, fg_exact); - fg_apply_sampling_margin(g_fg_boundary); - g_fg_verify = 1; } + /* The margin covers what the samples can miss between columns. */ + fg_apply_sampling_margin(g_fg_boundary); + /* Turn on the audit once the grid is ready. */ + if (fg_verify_env) + g_fg_verify = 1; G_important_message(_("Projecting (banded, per-thread PROJ context)...")); @@ -1272,50 +1256,13 @@ int main(int argc, char **argv) } int tilew = outcellhd.cols; int imin = 0, imax = -1; - /* Phase-1 neighbor seed. seed_h1 is the previous accepted height and is - * close to this band's. Take g_seed, the grid height just above - * seed_h1, and if it does not fit then nothing taller fits, so the walk - * starts at (g_seed+1)/2 and skips the tall full-width edge walks. Any - * miss starts from the full remaining height. The lattice and - * acceptance line are the same, so the accepted height and partition - * are identical and the hit only skips heights already shown not to - * fit. */ - int band_orows = outcellhd.rows - obr0; - int p1_seeded = 0; - if (seed_h1 > 0 && seed_h1 < band_orows) { - int gs = band_orows; - - while ((gs + 1) / 2 > seed_h1) - gs = (gs + 1) / 2; - if (!phase1_fits(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, obr0, gs, cap_bytes, cell_size, out_mult, - &poles)) { - band_orows = (gs + 1) / 2; - p1_seeded = 1; - } - } - for (;;) { - band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, obr0, obr0 + band_orows, 0, - outcellhd.cols, &imin, &imax, &poles, NULL); - int strip_rows = imax - imin + 1; - size_t strip_bytes = - strip_rows > 0 ? (size_t)strip_rows * incellhd.cols * cell_size - : 0; - size_t out_bytes = (size_t)band_orows * outcellhd.cols * cell_size; - if (!force_tilecache && - strip_bytes + out_mult * out_bytes <= cap_bytes) - break; - if (band_orows == 1) - break; /* height exhausted, fall through to column splitting */ - band_orows = (band_orows + 1) / 2; /* halve (round up), re-sample */ - } - if (band_orows > 1) { /* Phase-1 accepted a full-width band */ - seed_h1 = band_orows; - p1_bands++; - if (p1_seeded) - p1_hits++; - } + /* Grow the band while the strip and output still fit the cap, then step + * back one. */ + int band_orows = + force_tilecache + ? 1 + : fg_band_height(g_fg_boundary, obr0, cap_bytes, out_mult, + cell_size, incellhd.cols); if (band_orows == 1) { /* Phase 2, oblique only, finds the tallest grid height whose worst * column tile fits, then that height's widest fitting tile width. @@ -1401,6 +1348,22 @@ int main(int argc, char **argv) } t_size += rproj_wtime() - ts; + /* Walk the accepted band once and flag it when the grid strip is + * smaller than the walk. */ + if (g_fg_verify) { + int gi0, gi1, si0, si1, grid_rows, walk_rows; + + fg_span(g_fg_boundary, obr0, obr0 + band_orows, 0, outcellhd.cols, + &gi0, &gi1); + band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + y_center, obr0, obr0 + band_orows, 0, + outcellhd.cols, &si0, &si1, &poles, NULL); + grid_rows = gi1 >= gi0 ? gi1 - gi0 + 1 : 0; + walk_rows = si1 >= si0 ? si1 - si0 + 1 : 0; + if (grid_rows < walk_rows) + g_fg_band_audit_fail++; + } + int obr1 = obr0 + band_orows; n_bands++; int n_tiles = (outcellhd.cols + tilew - 1) / tilew; @@ -1635,10 +1598,9 @@ int main(int argc, char **argv) else G_debug(1, "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f " - "bands=%d tiles=%d seed_hits=%d phase2_bands=%d p1_hits=%d " - "p1_bands=%d", + "bands=%d tiles=%d seed_hits=%d phase2_bands=%d", t_size, t_fill, t_compute, t_write, n_bands, max_tiles, - seed_hits, phase2_bands, p1_hits, p1_bands); + seed_hits, phase2_bands); /* Close input map in its own env, then the output map. */ G_switch_env(); /* -> input */ diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index 6a2216ce6a8..0afa38c72d8 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -60,6 +60,9 @@ fg_build(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pole_set *poles, int variant); extern void fg_span(const struct footprint_grid *g, int obr0, int obr1, int obc0, int obc1, int *imin, int *imax); +extern int fg_band_height(const struct footprint_grid *g, int obr0, + size_t cap_bytes, int out_mult, int cell_size, + int in_cols); extern void fg_compare_variants(const struct footprint_grid *b, const struct footprint_grid *e); extern void fg_apply_sampling_margin(struct footprint_grid *g); From c82a0f95c31486e84e624f4cf5fbb27a9dcc5c2b Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Sat, 1 Aug 2026 15:06:52 -0700 Subject: [PATCH 26/32] r.proj: size tile widths from the footprint grid Tile widths now come from a scan of the footprint grid, with tiles built as runs of whole column blocks and a single over-cap check that routes to the serial fallback. The former tile-width search is removed and the output stays bitwise identical to the serial reference. --- raster/r.proj/footprint.c | 54 ++++++ raster/r.proj/main.c | 338 ++++++++------------------------------ raster/r.proj/r.proj.h | 5 + 3 files changed, 131 insertions(+), 266 deletions(-) diff --git a/raster/r.proj/footprint.c b/raster/r.proj/footprint.c index 26e3020a2cf..13a81f090ad 100644 --- a/raster/r.proj/footprint.c +++ b/raster/r.proj/footprint.c @@ -260,6 +260,60 @@ int fg_band_height(const struct footprint_grid *g, int obr0, size_t cap_bytes, return accepted; } +/* Number of column blocks in the grid. */ +int fg_num_blocks(const struct footprint_grid *g) +{ + return g->nb; +} + +/* First output column of block b. Block g->nb starts at the output width. */ +int fg_block_start(const struct footprint_grid *g, int b) +{ + return block_c0(g, b); +} + +/* Worst strip among the tiles that pack k whole blocks each across the band. */ +static int worst_ktile_rows(const struct footprint_grid *g, int obr0, int obr1, + int k) +{ + int worst = 0, tb; + + for (tb = 0; tb < g->nb; tb += k) { + int te = tb + k < g->nb ? tb + k : g->nb; + int imin, imax, rows; + + fg_span(g, obr0, obr1, block_c0(g, tb), block_c0(g, te), &imin, &imax); + rows = imax - imin + 1; + if (rows > worst) + worst = rows; + } + return worst; +} + +/* Widest tile in whole blocks whose worst strip and the output still fit the + * cap, or zero when even one block per tile busts. Reports the worst single + * block strip for the caller message. */ +int fg_tile_blocks(const struct footprint_grid *g, int obr0, int obr1, + size_t cap_bytes, int out_mult, int cell_size, int in_cols, + int *worst_block_rows) +{ + size_t out_bytes = (size_t)(obr1 - obr0) * g->ocols * cell_size; + int k; + + *worst_block_rows = worst_ktile_rows(g, obr0, obr1, 1); + if (out_mult * out_bytes > cap_bytes) + return 0; + for (k = g->nb; k >= 1; k--) { + int worst = worst_ktile_rows(g, obr0, obr1, k); + size_t strip_bytes = + worst > 0 ? (size_t)worst * in_cols * cell_size : 0; + + if (strip_bytes + out_mult * out_bytes <= cap_bytes) + return k; + } + return 0; +} + /* Reports how many cells the exact variant makes wider than the boundary * variant, with the largest widening on each side. */ void fg_compare_variants(const struct footprint_grid *b, diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 4da5bde5975..4b1e630ccf6 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -282,123 +282,6 @@ band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, fg_verify_emit(obr0, obr1, obc0, obc1, *imin, *imax); } -/* Largest input-row strip among the width-tilew column tiles that partition the - * band [obr0, obr1). Tiles load one at a time, so peak strip memory is the - * worst tile rather than the union of the band's tiles, and the fit search - * sizes this against the cap. It returns 0 when every tile projects entirely - * outside the input. */ -static int worst_tile_strip_rows(const struct Cell_head *ohd, - const struct Cell_head *ihd, - const struct pj_info *oproj, - const struct pj_info *iproj, - const struct pj_info *tproj, - const double *y_center, int obr0, int obr1, - int tilew, const struct pole_set *poles) -{ - int worst = 0, obc0; - - for (obc0 = 0; obc0 < ohd->cols; obc0 += tilew) { - int obc1 = obc0 + tilew; - int imin, imax, rows; - - if (obc1 > ohd->cols) - obc1 = ohd->cols; - band_input_row_span(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr1, - obc0, obc1, &imin, &imax, poles, NULL); - rows = imax - imin + 1; /* imax < imin (empty) -> <= 0, ignored */ - if (rows > worst) - worst = rows; - } - return worst; -} - -#define TILE_PROBE 16 /* tiles sampled by the Phase-2 width-search estimate */ - -/* Cheap lower-bound estimate of worst_tile_strip_rows, taking the largest strip - * among at most probe column tiles that are evenly spaced across the band width - * and always include the first and last. A subset max only prunes the Phase-2 - * search, and the chosen width is exact-validated by worst_tile_strip_rows - * before use. */ -static int est_worst_tile_strip_rows( - const struct Cell_head *ohd, const struct Cell_head *ihd, - const struct pj_info *oproj, const struct pj_info *iproj, - const struct pj_info *tproj, const double *y_center, int obr0, int obr1, - int tilew, int probe, const struct pole_set *poles) -{ - int ntiles = (ohd->cols + tilew - 1) / tilew; - int worst = 0, k; - - if (probe < 1) - probe = 1; - if (probe > ntiles) - probe = ntiles; - for (k = 0; k < probe; k++) { - int ti = (probe == 1) ? 0 : (int)((long)k * (ntiles - 1) / (probe - 1)); - int obc0 = ti * tilew; - int obc1 = obc0 + tilew; - int imin, imax, rows; - - if (obc1 > ohd->cols) - obc1 = ohd->cols; - band_input_row_span(ohd, ihd, oproj, iproj, tproj, y_center, obr0, obr1, - obc0, obc1, &imin, &imax, poles, NULL); - rows = imax - imin + 1; - if (rows > worst) - worst = rows; - } - return worst; -} - -/* Phase-2 fit test that returns 1 when a band of height h at obr0 fits the cap - * at some column-tile width, setting acc_tilew to that width through the same - * estimate then exact-validate steps the search uses. It returns 0 when no - * width fits or the output buffer alone exceeds the cap. */ -static int -phase2_width_fit(const struct Cell_head *ohd, const struct Cell_head *ihd, - const struct pj_info *oproj, const struct pj_info *iproj, - const struct pj_info *tproj, const double *y_center, int obr0, - int h, size_t cap_bytes, int cell_size, int out_mult, - int *acc_tilew, const struct pole_set *poles) -{ - size_t out_bytes = (size_t)h * ohd->cols * cell_size; - int tilew, est_fit; - - if (out_mult * out_bytes > cap_bytes) - return 0; - tilew = ohd->cols; - est_fit = 0; - for (;;) { - int est = - est_worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, y_center, - obr0, obr0 + h, tilew, TILE_PROBE, poles); - size_t est_bytes = est > 0 ? (size_t)est * ihd->cols * cell_size : 0; - if (est_bytes + out_mult * out_bytes <= cap_bytes) { - est_fit = 1; - break; - } - if (tilew == 1) - break; - tilew = (tilew + 1) / 2; - } - if (est_fit) { - for (;;) { - int worst = - worst_tile_strip_rows(ohd, ihd, oproj, iproj, tproj, y_center, - obr0, obr0 + h, tilew, poles); - size_t strip_bytes = - worst > 0 ? (size_t)worst * ihd->cols * cell_size : 0; - if (strip_bytes + out_mult * out_bytes <= cap_bytes) { - *acc_tilew = tilew; - return 1; - } - if (tilew == 1) - break; - tilew = (tilew + 1) / 2; - } - } - return 0; -} - /* Serial tile-cache fallback for the oblique and large-halo corner. When even * one output row's full-width strip busts the cap, this finishes the run from * row obr0 with the classic readcell cache and CVAL kernels, exactly as serial @@ -1116,9 +999,7 @@ int main(int argc, char **argv) * one thread. The fallback bail flushes are untimed. */ double t_size = 0.0, t_fill = 0.0, t_compute = 0.0, t_write = 0.0; int n_bands = 0; - int max_tiles = 1; /* most column tiles used by any single band */ - int seed_h = 0, seed_w = 0; /* previous Phase-2 band's accepted sizing */ - int seed_hits = 0, phase2_bands = 0; /* seed hit rate on the Phase-2 path */ + int max_tiles = 1; /* most column tiles used by any single band */ /* Output-row center northings from the serial recurrence, starting at north * minus ns_res/2 and subtracting ns_res per row. The direct form differs by @@ -1211,140 +1092,64 @@ int main(int argc, char **argv) int pending_r0 = 0, pending_r1 = 0; int obr0 = 0; while (obr0 < outcellhd.rows) { - /* Fit search. Phase 1 halves the band height until the full-width strip - * plus output buffer fit the cap. Phase 2 runs only when a single - * full-width row still busts the cap, splitting the row into column - * tiles and halving tile width until the worst tile's strip fits. - * Strips are full input width because the raster API reads whole rows, - * so width splitting shrinks a tile's input row span rather than its - * width. Easy pairs never leave Phase 1. */ + /* Size this band. Take the tallest full-width band that fits, and when + * even one full-width row does not fit split it into whole column + * blocks and take the widest tile that fits. */ double ts = rproj_wtime(); - /* Band-0 early-out for the wide-input corner. When a single output row - * at the finest tiling already busts the cap, take the serial fallback - * now instead of running the search only to bail. This is probed once - * at the first band. Later-band pole busts still fall through to the - * Phase-2 bail, and force_tilecache is deliberately not handled here so - * the override keeps routing through that bail. */ - if (obr0 == 0) { - size_t out1 = (size_t)outcellhd.cols * cell_size; - int worst1 = worst_tile_strip_rows(&outcellhd, &incellhd, &oproj, - &iproj, &tproj, y_center, obr0, - obr0 + 1, 1, &poles); - size_t strip1 = - worst1 > 0 ? (size_t)worst1 * incellhd.cols * cell_size : 0; - if (strip1 + out1 > cap_bytes) { - int needed_mb = - (int)ceil((double)(strip1 + out1) / (1024.0 * 1024.0)) + 1; - G_warning(_("Memory cap (%.1f MB) is below what one output row " - "needs (input footprint %d rows, %.1f MB). Falling " - "back to the serial tile-cache path for output " - "rows %d-%d; this path is slower. Raise memory= to " - "at least %d MB to use the parallel path."), - cap_mb, worst1, - (double)(strip1 + out1) / (1024.0 * 1024.0), obr0, - outcellhd.rows - 1, needed_mb); - /* Flush the deferred band before the fallback writes from obr0 - * (in-order). */ - flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, - &pending_out, pending_r0, pending_r1); - fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, - &iproj, &tproj, &incellhd, &outcellhd, - y_center, obr0, memory->answer); - used_fallback = 1; - goto fallback_done; - } - } - int tilew = outcellhd.cols; int imin = 0, imax = -1; - /* Grow the band while the strip and output still fit the cap, then step - * back one. */ int band_orows = force_tilecache ? 1 : fg_band_height(g_fg_boundary, obr0, cap_bytes, out_mult, cell_size, incellhd.cols); + int tile_blocks = fg_num_blocks(g_fg_boundary); if (band_orows == 1) { - /* Phase 2, oblique only, finds the tallest grid height whose worst - * column tile fits, then that height's widest fitting tile width. - * seed_h is the previous Phase-2 height and is close to H*. Take - * g_seed just above seed_h, and if it does not fit then nothing - * taller fits, so the walk starts at (g_seed+1)/2. A miss starts - * from the full remaining height. The grid and phase2_width_fit - * acceptance are the same, so H*, W*, and the partition are - * identical. */ - phase2_bands++; - int start_h = outcellhd.rows - obr0; - if (seed_w > 0 && seed_h < start_h) { - int gs = start_h, w; - - while ((gs + 1) / 2 > seed_h) - gs = (gs + 1) / 2; - if (!phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, - &tproj, y_center, obr0, gs, cap_bytes, - cell_size, out_mult, &w, &poles)) { - start_h = (gs + 1) / 2; - seed_hits++; + int worst_block_rows = 0; + + tile_blocks = + force_tilecache + ? 0 + : fg_tile_blocks(g_fg_boundary, obr0, obr0 + band_orows, + cap_bytes, out_mult, cell_size, + incellhd.cols, &worst_block_rows); + if (tile_blocks == 0) { + /* Even the finest tiling busts the cap, so finish from obr0 on + * the serial tile-cache path. */ + if (force_tilecache) { + G_warning( + _("R_PROJ_FORCE_TILECACHE is set: taking the serial " + "tile-cache path for all output rows (testing " + "override).")); } - } - band_orows = start_h; - for (;;) { - if (!force_tilecache && - phase2_width_fit(&outcellhd, &incellhd, &oproj, &iproj, - &tproj, y_center, obr0, band_orows, - cap_bytes, cell_size, out_mult, &tilew, - &poles)) - break; - if (band_orows == 1) { - /* A single output row at minimum width still over the cap - * is a singular or large-halo case, so take the serial - * tile-cache path. This is also reached from band 0 when - * R_PROJ_FORCE_TILECACHE routes normal data here for - * testing. */ - if (force_tilecache) { - G_warning( - _("R_PROJ_FORCE_TILECACHE is set: taking the " - "serial tile-cache path for all output rows " - "(testing override).")); - } - else { - size_t out1 = (size_t)outcellhd.cols * cell_size; - int worst = worst_tile_strip_rows( - &outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, obr0, obr0 + 1, 1, &poles); - size_t strip_bytes = - worst > 0 - ? (size_t)worst * incellhd.cols * cell_size - : 0; - int needed_mb = (int)ceil((double)(strip_bytes + out1) / - (1024.0 * 1024.0)) + - 1; - G_warning( - _("Memory cap (%.1f MB) is below what one output " - "row needs (input footprint %d rows, %.1f MB). " - "Falling back to the serial tile-cache path for " - "output rows %d-%d; this path is slower. Raise " - "memory= to at least %d MB to use the parallel " - "path."), - cap_mb, worst, - (double)(strip_bytes + out1) / (1024.0 * 1024.0), - obr0, outcellhd.rows - 1, needed_mb); - } - /* Flush the deferred band before the fallback writes from - * obr0. This band's compute region did not run, so its - * writer never fired. */ - flush_pending_band(fdo, cell_type, outcellhd.cols, - cell_size, &pending_out, pending_r0, - pending_r1); - fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, - &iproj, &tproj, &incellhd, &outcellhd, - y_center, obr0, memory->answer); - used_fallback = 1; - goto fallback_done; + else { + size_t out1 = (size_t)outcellhd.cols * cell_size; + size_t strip_bytes = worst_block_rows > 0 + ? (size_t)worst_block_rows * + incellhd.cols * cell_size + : 0; + int needed_mb = (int)ceil((double)(strip_bytes + out1) / + (1024.0 * 1024.0)) + + 1; + G_warning( + _("Memory cap (%.1f MB) is below what one output row " + "needs (input footprint %d rows, %.1f MB). Falling " + "back to the serial tile-cache path for output rows " + "%d-%d; this path is slower. Raise memory= to at " + "least %d MB to use the parallel path."), + cap_mb, worst_block_rows, + (double)(strip_bytes + out1) / (1024.0 * 1024.0), obr0, + outcellhd.rows - 1, needed_mb); } - band_orows = (band_orows + 1) / 2; + /* Flush the deferred band before the fallback writes from obr0 + * in order. */ + flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, + &pending_out, pending_r0, pending_r1); + fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, + &iproj, &tproj, &incellhd, &outcellhd, + y_center, obr0, memory->answer); + used_fallback = 1; + goto fallback_done; } - seed_h = band_orows; - seed_w = tilew; } t_size += rproj_wtime() - ts; @@ -1366,7 +1171,8 @@ int main(int argc, char **argv) int obr1 = obr0 + band_orows; n_bands++; - int n_tiles = (outcellhd.cols + tilew - 1) / tilew; + int nb = fg_num_blocks(g_fg_boundary); + int n_tiles = (nb + tile_blocks - 1) / tile_blocks; if (n_tiles > max_tiles) max_tiles = n_tiles; @@ -1377,25 +1183,26 @@ int main(int argc, char **argv) G_malloc((size_t)band_orows * outcellhd.cols * cell_size); /* Column tiles are processed one at a time, so peak strip memory is the - * worst tile rather than the band's union. A tilew equal to cols is the - * single-tile fast path. */ - for (int obc0 = 0; obc0 < outcellhd.cols; obc0 += tilew) { - int obc1 = obc0 + tilew; - if (obc1 > outcellhd.cols) - obc1 = outcellhd.cols; - - /* Per-tile input row span. The strip is full input width because - * the raster API reads whole rows, so columns are not cropped. */ - int pole_widened = 0; - - band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, obr0, obr1, obc0, obc1, &imin, &imax, - &poles, &pole_widened); - if (pole_widened) - G_verbose_message( - _("Pole (input row %d) in output tile rows [%d, %d) cols " - "[%d, %d): input strip extended to reach it"), - (int)poles.ri[pole_widened - 1], obr0, obr1, obc0, obc1); + * worst tile rather than the band's union. A single tile spanning every + * block is the full-width fast path. */ + for (int tb = 0; tb < nb; tb += tile_blocks) { + int te = tb + tile_blocks < nb ? tb + tile_blocks : nb; + int obc0 = fg_block_start(g_fg_boundary, tb); + int obc1 = fg_block_start(g_fg_boundary, te); + + /* Fill spans come from the grid. The strip is full input width + * because the raster API reads whole rows, so columns are not + * cropped. */ + fg_span(g_fg_boundary, obr0, obr1, obc0, obc1, &imin, &imax); + /* Under the verify flag walk the tile too so the hook checks the + * grid against the walk. */ + if (g_fg_verify) { + int wi0, wi1; + + band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, + &tproj, y_center, obr0, obr1, obc0, obc1, + &wi0, &wi1, &poles, NULL); + } int strip_rows = imax - imin + 1; /* Serial strip load, since a single fd makes get_row unsafe to @@ -1598,9 +1405,8 @@ int main(int argc, char **argv) else G_debug(1, "PHASE_TIMERS size=%.4f fill=%.4f compute=%.4f write=%.4f " - "bands=%d tiles=%d seed_hits=%d phase2_bands=%d", - t_size, t_fill, t_compute, t_write, n_bands, max_tiles, - seed_hits, phase2_bands); + "bands=%d tiles=%d", + t_size, t_fill, t_compute, t_write, n_bands, max_tiles); /* Close input map in its own env, then the output map. */ G_switch_env(); /* -> input */ diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index 0afa38c72d8..5d51940565d 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -63,6 +63,11 @@ extern void fg_span(const struct footprint_grid *g, int obr0, int obr1, extern int fg_band_height(const struct footprint_grid *g, int obr0, size_t cap_bytes, int out_mult, int cell_size, int in_cols); +extern int fg_num_blocks(const struct footprint_grid *g); +extern int fg_block_start(const struct footprint_grid *g, int b); +extern int fg_tile_blocks(const struct footprint_grid *g, int obr0, int obr1, + size_t cap_bytes, int out_mult, int cell_size, + int in_cols, int *worst_block_rows); extern void fg_compare_variants(const struct footprint_grid *b, const struct footprint_grid *e); extern void fg_apply_sampling_margin(struct footprint_grid *g); From 8ef0564e3dd1a333bd0070d8aa9838ca2ef20cab Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Sun, 2 Aug 2026 00:53:48 -0700 Subject: [PATCH 27/32] r.proj: match the serial rounding for large integer values The parallel nearest path now rounds each input value through a 32-bit float as the strip is read, the same step the serial cache-based read uses. Outputs then match the serial reference for every input, including integers above 2^24 where the float step changes the value. --- raster/r.proj/main.c | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 4b1e630ccf6..f786ccb41a6 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -93,6 +93,28 @@ struct menu menu[] = { static char *make_ipol_list(void); static char *make_ipol_desc(void); +/* Round whole numbers through 32 bit float so every read path + returns the same values. */ +static void quantize_cell_row(void *row, int cols, int cell_type) +{ + int i; + + if (cell_type == CELL_TYPE) { + CELL *p = row; + + for (i = 0; i < cols; i++) + if (!Rast_is_c_null_value(&p[i])) + Rast_set_f_value(&p[i], (FCELL)p[i], CELL_TYPE); + } + else if (cell_type == DCELL_TYPE) { + DCELL *p = row; + + for (i = 0; i < cols; i++) + if (!Rast_is_d_null_value(&p[i])) + Rast_set_f_value(&p[i], (FCELL)p[i], DCELL_TYPE); + } +} + /* Nearest-neighbor read from an in-RAM strip holding input rows [imin, imax]. * The col_idx and row_idx values are full-map indices and the strip is * addressed relative to imin. A sample that lands inside the input map but @@ -1270,6 +1292,11 @@ int main(int argc, char **argv) r, cell_type); } G_switch_env(); /* -> output */ + for (int r = read_from; r <= imax; r++) + quantize_cell_row((unsigned char *)strip + + (size_t)(r - imin) * + incellhd.cols * cell_size, + incellhd.cols, cell_type); } t_fill += rproj_wtime() - t0; /* Record what the window now holds. A tiled band leaves win From d6e0554a540d5e1e527e110388bd5893a72401e9 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Sun, 2 Aug 2026 12:52:16 -0700 Subject: [PATCH 28/32] r.proj: remove the grid verification scaffolding The footprint grid was validated against the perimeter-walk search while it was being brought up. That verification path, its environment flag, and the counters it printed are no longer needed, and the runtime under-size check remains as the backstop. --- raster/r.proj/footprint.c | 116 ++++++----------------- raster/r.proj/main.c | 188 +------------------------------------- raster/r.proj/r.proj.h | 31 +------ 3 files changed, 30 insertions(+), 305 deletions(-) diff --git a/raster/r.proj/footprint.c b/raster/r.proj/footprint.c index 13a81f090ad..cf3f7db479e 100644 --- a/raster/r.proj/footprint.c +++ b/raster/r.proj/footprint.c @@ -7,7 +7,6 @@ #include #include -#include #include #include @@ -19,7 +18,6 @@ struct fg_cell { }; struct footprint_grid { - int variant; /* FG_BOUNDARY or FG_EXACT */ int grows, nb; /* grid rows and column blocks */ int ocols; /* output columns */ int irows; /* input rows */ @@ -89,85 +87,59 @@ static void fold_poles(const struct footprint_grid *g, } } -/* Builds the grid using boundary samples or every column. */ +/* Builds the grid from block boundary samples. */ struct footprint_grid * fg_build(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, const double *y_center, - const struct pole_set *poles, int variant) + const struct pole_set *poles) { struct footprint_grid *g = G_malloc(sizeof(*g)); int r, b; - double *bnd = NULL; + double *bnd; - g->variant = variant; g->grows = ohd->rows; g->nb = ohd->cols < 32 ? ohd->cols : 32; g->ocols = ohd->cols; g->irows = ihd->rows; g->cell = G_malloc((size_t)g->grows * g->nb * sizeof(struct fg_cell)); - - if (variant == FG_BOUNDARY) - bnd = G_malloc((size_t)(g->nb + 1) * sizeof(double)); + bnd = G_malloc((size_t)(g->nb + 1) * sizeof(double)); for (r = 0; r < g->grows; r++) { - if (variant == FG_BOUNDARY) { - /* Sample the NB plus one block boundaries for this row. The last - * boundary uses the final valid column. */ - int k; - - for (k = 0; k <= g->nb; k++) { - int c = block_c0(g, k); - - if (c > g->ocols - 1) - c = g->ocols - 1; - if (!sample_ri(ohd, ihd, oproj, iproj, tproj, y_center, r, c, - &bnd[k])) - bnd[k] = - DBL_MAX; /* a failed sample is left out of the range */ - } + /* Sample the NB plus one block boundaries for this row. The last + * boundary uses the final valid column. */ + int k; + + for (k = 0; k <= g->nb; k++) { + int c = block_c0(g, k); + + if (c > g->ocols - 1) + c = g->ocols - 1; + if (!sample_ri(ohd, ihd, oproj, iproj, tproj, y_center, r, c, + &bnd[k])) + bnd[k] = DBL_MAX; /* a failed sample is left out of the range */ } for (b = 0; b < g->nb; b++) { struct fg_cell *cell = &g->cell[(size_t)r * g->nb + b]; + double lo = bnd[b] < bnd[b + 1] ? bnd[b] : bnd[b + 1]; + double hi = bnd[b] > bnd[b + 1] ? bnd[b] : bnd[b + 1]; cell->rmin = DBL_MAX; cell->rmax = -DBL_MAX; - if (variant == FG_BOUNDARY) { - double lo = bnd[b] < bnd[b + 1] ? bnd[b] : bnd[b + 1]; - double hi = bnd[b] > bnd[b + 1] ? bnd[b] : bnd[b + 1]; - - if (bnd[b] != DBL_MAX && bnd[b + 1] != DBL_MAX) { - cell->rmin = lo; - cell->rmax = hi; - } - else if (bnd[b] != DBL_MAX) { - cell->rmin = cell->rmax = bnd[b]; - } - else if (bnd[b + 1] != DBL_MAX) { - cell->rmin = cell->rmax = bnd[b + 1]; - } + if (bnd[b] != DBL_MAX && bnd[b + 1] != DBL_MAX) { + cell->rmin = lo; + cell->rmax = hi; } - else { - /* Scan every column in the block. */ - int c0 = block_c0(g, b), c1 = block_c0(g, b + 1), c; - - for (c = c0; c < c1; c++) { - double ri; - - if (!sample_ri(ohd, ihd, oproj, iproj, tproj, y_center, r, - c, &ri)) - continue; - if (ri < cell->rmin) - cell->rmin = ri; - if (ri > cell->rmax) - cell->rmax = ri; - } + else if (bnd[b] != DBL_MAX) { + cell->rmin = cell->rmax = bnd[b]; + } + else if (bnd[b + 1] != DBL_MAX) { + cell->rmin = cell->rmax = bnd[b + 1]; } fold_poles(g, ohd, poles, r, b, cell); } } - if (bnd) - G_free(bnd); + G_free(bnd); return g; } @@ -314,43 +286,11 @@ int fg_tile_blocks(const struct footprint_grid *g, int obr0, int obr1, return 0; } -/* Reports how many cells the exact variant makes wider than the boundary - * variant, with the largest widening on each side. */ -void fg_compare_variants(const struct footprint_grid *b, - const struct footprint_grid *e) -{ - size_t n = (size_t)b->grows * b->nb, i; - long differ = 0; - double max_lo_gap = 0.0, max_hi_gap = 0.0; - - for (i = 0; i < n; i++) { - const struct fg_cell *cb = &b->cell[i], *ce = &e->cell[i]; - double lo_gap, hi_gap; - - if (cb->rmax < cb->rmin && ce->rmax < ce->rmin) - continue; - lo_gap = cb->rmin - ce->rmin; /* exact reaches this much lower */ - hi_gap = ce->rmax - cb->rmax; /* exact reaches this much higher */ - if (lo_gap > 0.0 || hi_gap > 0.0) { - differ++; - if (lo_gap > max_lo_gap) - max_lo_gap = lo_gap; - if (hi_gap > max_hi_gap) - max_hi_gap = hi_gap; - } - } - fprintf(stderr, - "FG_VAR cells=%ld differ=%ld max_lo_gap=%.3f max_hi_gap=%.3f\n", - (long)n, differ, max_lo_gap, max_hi_gap); -} - -/* Widens every non-empty cell of a boundary grid by the sampling margin. */ +/* Widens every non-empty cell by the sampling margin. */ void fg_apply_sampling_margin(struct footprint_grid *g) { size_t n = (size_t)g->grows * g->nb, i; - if (g->variant != FG_BOUNDARY) - return; for (i = 0; i < n; i++) { struct fg_cell *cell = &g->cell[i]; diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index f786ccb41a6..8efc5b4089e 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -157,152 +157,6 @@ static const strip_func strip_kernels[] = { /* Footprint grid used for comparison and the counters printed at the end. */ static struct footprint_grid *g_fg_boundary = NULL; -static int g_fg_verify = 0; -static long g_fg_ncmp = 0; /* comparisons made */ -static long g_fg_fail = 0; /* cover failures */ -static int g_fg_min_slack = 0; /* smallest margin between grid and search */ -static int g_fg_max_overread = - 0; /* most extra input rows the grid would load */ -static int g_fg_have_stats = 0; /* set once a non-empty span is compared */ -static long g_fg_band_audit_fail = - 0; /* bands whose grid strip came out smaller than the walk */ - -/* Compares one search span against the grid span and records the result. Prints - * a line only when the grid fails to cover the search. */ -static void fg_verify_emit(int obr0, int obr1, int obc0, int obc1, int s_imin, - int s_imax) -{ - int g_imin, g_imax, cover, low_slack, high_slack, slack, overread; - - if (!g_fg_verify) - return; - fg_span(g_fg_boundary, obr0, obr1, obc0, obc1, &g_imin, &g_imax); - g_fg_ncmp++; - if (s_imax < s_imin) /* empty search span is always covered */ - return; - cover = g_imin <= s_imin && g_imax >= s_imax; - low_slack = s_imin - g_imin; - high_slack = g_imax - s_imax; - slack = low_slack < high_slack ? low_slack : high_slack; - overread = (g_imax - g_imin) - (s_imax - s_imin); - if (!g_fg_have_stats || slack < g_fg_min_slack) - g_fg_min_slack = slack; - if (overread > g_fg_max_overread) - g_fg_max_overread = overread; - g_fg_have_stats = 1; - if (!cover) { - g_fg_fail++; - fprintf( - stderr, - "FG_CMP r[%d,%d) c[%d,%d) search=[%d,%d] grid=[%d,%d] cover=0\n", - obr0, obr1, obc0, obc1, s_imin, s_imax, g_imin, g_imax); - } -} - -/* Prints one line with the totals from all comparisons. */ -static void fg_verify_summary(void) -{ - if (!g_fg_verify) - return; - fprintf(stderr, - "FG_SUM comparisons=%ld cover_fail=%ld min_slack=%d " - "max_overread=%d fg_band_audit_fail=%ld\n", - g_fg_ncmp, g_fg_fail, g_fg_have_stats ? g_fg_min_slack : 0, - g_fg_max_overread, g_fg_band_audit_fail); -} - -/* Edge-walk of an output tile [obr0, obr1) by [obc0, obc1) projected into input - * space, returning the min and max input row it touches plus a 2-cell margin, - * clamped to the input map. It walks the tile perimeter of top and bottom rows - * and left and right columns so a curved transform's interior-edge extremum is - * caught, which corner-only sampling would miss. It runs serially before the - * parallel region, so the shared tproj is safe. It returns imax below imin when - * the tile projects entirely outside the input. */ -static void -band_input_row_span(const struct Cell_head *ohd, const struct Cell_head *ihd, - const struct pj_info *oproj, const struct pj_info *iproj, - const struct pj_info *tproj, const double *y_center, - int obr0, int obr1, int obc0, int obc1, int *imin, - int *imax, const struct pole_set *poles, int *pole_widened) -{ - double rmin = 1e300, rmax = -1e300; - int e, r, c; - - /* top edge (row obr0) and bottom edge (row obr1-1), tile columns */ - for (e = 0; e < 2; e++) { - int orow = (e == 0) ? obr0 : (obr1 - 1); - double y = y_center[orow]; - for (c = obc0; c < obc1; c++) { - double x = ohd->west + (c + 0.5) * ohd->ew_res; - double xx = x, yy = y; - if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &xx, &yy, NULL) < 0) - continue; - double ri = (ihd->north - yy) / ihd->ns_res; - if (ri < rmin) - rmin = ri; - if (ri > rmax) - rmax = ri; - } - } - /* left edge (col obc0) and right edge (col obc1-1), all band rows */ - for (e = 0; e < 2; e++) { - int ocol = (e == 0) ? obc0 : (obc1 - 1); - double x = ohd->west + (ocol + 0.5) * ohd->ew_res; - for (r = obr0; r < obr1; r++) { - double y = y_center[r]; - double xx = x, yy = y; - if (GPJ_transform(oproj, iproj, tproj, PJ_FWD, &xx, &yy, NULL) < 0) - continue; - double ri = (ihd->north - yy) / ihd->ns_res; - if (ri < rmin) - rmin = ri; - if (ri > rmax) - rmax = ri; - } - } - - /* Fold in any pole whose output point lies in this tile, since the - * perimeter walk cannot see an interior latitude extremum. A pole on a tile - * edge is caught by both adjacent tiles, which only widens a strip that is - * loaded anyway. This comes before the empty-tile check so a pole inside an - * otherwise outside tile still yields a valid span. */ - if (poles) { - double x_lo = ohd->west + obc0 * ohd->ew_res; - double x_hi = ohd->west + obc1 * ohd->ew_res; - double y_lo = ohd->north - obr1 * ohd->ns_res; - double y_hi = ohd->north - obr0 * ohd->ns_res; - int k; - - for (k = 0; k < poles->n; k++) { - if (poles->ox[k] < x_lo || poles->ox[k] > x_hi || - poles->oy[k] < y_lo || poles->oy[k] > y_hi) - continue; - if (poles->ri[k] < rmin) - rmin = poles->ri[k]; - if (poles->ri[k] > rmax) - rmax = poles->ri[k]; - if (pole_widened) - *pole_widened = k + 1; /* 1-based pole index, 0 == none */ - } - } - - if (rmax < rmin) { /* band projects entirely outside the input */ - *imin = 0; - *imax = -1; - fg_verify_emit(obr0, obr1, obc0, obc1, *imin, *imax); - return; - } - - int lo = (int)floor(rmin) - 2; /* 2-cell margin for interp stencils */ - int hi = (int)floor(rmax) + 2; - if (lo < 0) - lo = 0; - if (hi > ihd->rows - 1) - hi = ihd->rows - 1; - *imin = lo; - *imax = hi; - fg_verify_emit(obr0, obr1, obc0, obc1, *imin, *imax); -} /* Serial tile-cache fallback for the oblique and large-halo corner. When even * one output row's full-width strip busts the cap, this finishes the run from @@ -1074,21 +928,9 @@ int main(int argc, char **argv) /* Build the grid that sizes band heights. */ g_fg_boundary = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, &poles, FG_BOUNDARY); - /* Under the verify flag build the exact grid and compare it before the - * margin is added. */ - int fg_verify_env = getenv("R_PROJ_FG_VERIFY") != NULL; - struct footprint_grid *fg_exact = NULL; - if (fg_verify_env) { - fg_exact = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, &poles, FG_EXACT); - fg_compare_variants(g_fg_boundary, fg_exact); - } + y_center, &poles); /* The margin covers what the samples can miss between columns. */ fg_apply_sampling_margin(g_fg_boundary); - /* Turn on the audit once the grid is ready. */ - if (fg_verify_env) - g_fg_verify = 1; G_important_message(_("Projecting (banded, per-thread PROJ context)...")); @@ -1175,22 +1017,6 @@ int main(int argc, char **argv) } t_size += rproj_wtime() - ts; - /* Walk the accepted band once and flag it when the grid strip is - * smaller than the walk. */ - if (g_fg_verify) { - int gi0, gi1, si0, si1, grid_rows, walk_rows; - - fg_span(g_fg_boundary, obr0, obr0 + band_orows, 0, outcellhd.cols, - &gi0, &gi1); - band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, obr0, obr0 + band_orows, 0, - outcellhd.cols, &si0, &si1, &poles, NULL); - grid_rows = gi1 >= gi0 ? gi1 - gi0 + 1 : 0; - walk_rows = si1 >= si0 ? si1 - si0 + 1 : 0; - if (grid_rows < walk_rows) - g_fg_band_audit_fail++; - } - int obr1 = obr0 + band_orows; n_bands++; int nb = fg_num_blocks(g_fg_boundary); @@ -1216,15 +1042,6 @@ int main(int argc, char **argv) * because the raster API reads whole rows, so columns are not * cropped. */ fg_span(g_fg_boundary, obr0, obr1, obc0, obc1, &imin, &imax); - /* Under the verify flag walk the tile too so the hook checks the - * grid against the walk. */ - if (g_fg_verify) { - int wi0, wi1; - - band_input_row_span(&outcellhd, &incellhd, &oproj, &iproj, - &tproj, y_center, obr0, obr1, obc0, obc1, - &wi0, &wi1, &poles, NULL); - } int strip_rows = imax - imin + 1; /* Serial strip load, since a single fd makes get_row unsafe to @@ -1416,11 +1233,8 @@ int main(int argc, char **argv) t_write += rproj_wtime() - tw; } G_free(y_center); - fg_verify_summary(); if (g_fg_boundary) fg_free(g_fg_boundary); - if (fg_exact) - fg_free(fg_exact); /* Single free site for the rolling window. Normal completion and both * fallback_done bails converge here, so one free covers every path. win is * NULL when a bail fired before any band allocated it. */ diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index 5d51940565d..76dc9ff00d3 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -51,13 +51,12 @@ struct pole_set { /* Footprint grid of input row spans for the output map, built in footprint.c. */ -enum fg_variant { FG_BOUNDARY, FG_EXACT }; struct footprint_grid; extern struct footprint_grid * fg_build(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pj_info *oproj, const struct pj_info *iproj, const struct pj_info *tproj, const double *y_center, - const struct pole_set *poles, int variant); + const struct pole_set *poles); extern void fg_span(const struct footprint_grid *g, int obr0, int obr1, int obc0, int obc1, int *imin, int *imax); extern int fg_band_height(const struct footprint_grid *g, int obr0, @@ -68,8 +67,6 @@ extern int fg_block_start(const struct footprint_grid *g, int b); extern int fg_tile_blocks(const struct footprint_grid *g, int obr0, int obr1, size_t cap_bytes, int out_mult, int cell_size, int in_cols, int *worst_block_rows); -extern void fg_compare_variants(const struct footprint_grid *b, - const struct footprint_grid *e); extern void fg_apply_sampling_margin(struct footprint_grid *g); extern void fg_free(struct footprint_grid *g); @@ -119,8 +116,6 @@ extern void strip_cubic_f(void *, void *, int, double, double, extern void strip_lanczos_f(void *, void *, int, double, double, struct Cell_head *, int, int); -#if 1 - #define BKIDX(c, y, x) ((y) * (c)->stride + (x)) #define BKPTR(c, y, x) ((c)->grid[BKIDX((c), (y), (x))]) #define BLOCK(c, y, x) \ @@ -128,28 +123,4 @@ extern void strip_lanczos_f(void *, void *, int, double, double, : get_block((c), BKIDX((c), (y), (x)))) #define CVAL(c, y, x) ((*BLOCK((c), HI((y)), HI((x))))[LO((y))][LO((x))]) -#else - -static inline int BKIDX(const struct cache *c, int y, int x) -{ - return y * c->stride + x; -} - -static inline block *BKPTR(const struct cache *c, int y, int x) -{ - return c->grid[BKIDX(c, y, x)]; -} - -static inline block *BLOCK(struct cache *c, int y, int x) -{ - return BKPTR(c, y, x) ? BKPTR(c, y, x) : get_block(c, BKIDX(c, y, x)); -} - -static inline FCELL *CPTR(struct cache *c, int y, int x) -{ - return &(*BLOCK(c, HI(y), HI(x)))[LO(y)][LO(x)]; -} - -#endif - #endif From 2d7aaa405e9d82de2a23b369c91413aaef4400a2 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Sun, 2 Aug 2026 16:23:57 -0700 Subject: [PATCH 29/32] r.proj: rename the sizing variables for clarity The nearest strip reader is renamed to strip_nearest to match its sibling kernels, the footprint grid pointer to band_grid now that there is only one grid, and the pole row field to pole_row. --- raster/r.proj/footprint.c | 8 ++++---- raster/r.proj/main.c | 40 +++++++++++++++++++-------------------- raster/r.proj/r.proj.h | 2 +- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/raster/r.proj/footprint.c b/raster/r.proj/footprint.c index cf3f7db479e..0b79b0347a5 100644 --- a/raster/r.proj/footprint.c +++ b/raster/r.proj/footprint.c @@ -80,10 +80,10 @@ static void fold_poles(const struct footprint_grid *g, if (poles->ox[k] < x_lo || poles->ox[k] > x_hi || poles->oy[k] < y_lo || poles->oy[k] > y_hi) continue; - if (poles->ri[k] < cell->rmin) - cell->rmin = poles->ri[k]; - if (poles->ri[k] > cell->rmax) - cell->rmax = poles->ri[k]; + if (poles->pole_row[k] < cell->rmin) + cell->rmin = poles->pole_row[k]; + if (poles->pole_row[k] > cell->rmax) + cell->rmax = poles->pole_row[k]; } } diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 8efc5b4089e..044bbe80cbc 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -120,9 +120,9 @@ static void quantize_cell_row(void *row, int cols, int cell_type) * addressed relative to imin. A sample that lands inside the input map but * outside the loaded strip means the band was under-sized, which the guard * below catches. */ -static void interpolate_strip(void *strip, void *obufptr, int cell_type, - double col_idx, double row_idx, - struct Cell_head *incellhd, int imin, int imax) +static void strip_nearest(void *strip, void *obufptr, int cell_type, + double col_idx, double row_idx, + struct Cell_head *incellhd, int imin, int imax) { int c = (int)floor(col_idx); int r = (int)floor(row_idx); @@ -152,11 +152,11 @@ static void interpolate_strip(void *strip, void *obufptr, int cell_type, * of menu[i].method. Slot 0 is nearest above and slots 1 to 6 come from * interp_strip.c. */ static const strip_func strip_kernels[] = { - interpolate_strip, strip_bilinear, strip_cubic, strip_lanczos, - strip_bilinear_f, strip_cubic_f, strip_lanczos_f}; + strip_nearest, strip_bilinear, strip_cubic, strip_lanczos, + strip_bilinear_f, strip_cubic_f, strip_lanczos_f}; /* Footprint grid used for comparison and the counters printed at the end. */ -static struct footprint_grid *g_fg_boundary = NULL; +static struct footprint_grid *band_grid = NULL; /* Serial tile-cache fallback for the oblique and large-halo corner. When even * one output row's full-width strip busts the cap, this finishes the run from @@ -921,16 +921,16 @@ int main(int argc, char **argv) ri = incellhd.rows - 1; poles.ox[poles.n] = px; poles.oy[poles.n] = py; - poles.ri[poles.n] = ri; + poles.pole_row[poles.n] = ri; poles.n++; } } /* Build the grid that sizes band heights. */ - g_fg_boundary = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, - y_center, &poles); + band_grid = fg_build(&outcellhd, &incellhd, &oproj, &iproj, &tproj, + y_center, &poles); /* The margin covers what the samples can miss between columns. */ - fg_apply_sampling_margin(g_fg_boundary); + fg_apply_sampling_margin(band_grid); G_important_message(_("Projecting (banded, per-thread PROJ context)...")); @@ -964,16 +964,16 @@ int main(int argc, char **argv) int band_orows = force_tilecache ? 1 - : fg_band_height(g_fg_boundary, obr0, cap_bytes, out_mult, + : fg_band_height(band_grid, obr0, cap_bytes, out_mult, cell_size, incellhd.cols); - int tile_blocks = fg_num_blocks(g_fg_boundary); + int tile_blocks = fg_num_blocks(band_grid); if (band_orows == 1) { int worst_block_rows = 0; tile_blocks = force_tilecache ? 0 - : fg_tile_blocks(g_fg_boundary, obr0, obr0 + band_orows, + : fg_tile_blocks(band_grid, obr0, obr0 + band_orows, cap_bytes, out_mult, cell_size, incellhd.cols, &worst_block_rows); if (tile_blocks == 0) { @@ -1019,7 +1019,7 @@ int main(int argc, char **argv) int obr1 = obr0 + band_orows; n_bands++; - int nb = fg_num_blocks(g_fg_boundary); + int nb = fg_num_blocks(band_grid); int n_tiles = (nb + tile_blocks - 1) / tile_blocks; if (n_tiles > max_tiles) max_tiles = n_tiles; @@ -1035,19 +1035,19 @@ int main(int argc, char **argv) * block is the full-width fast path. */ for (int tb = 0; tb < nb; tb += tile_blocks) { int te = tb + tile_blocks < nb ? tb + tile_blocks : nb; - int obc0 = fg_block_start(g_fg_boundary, tb); - int obc1 = fg_block_start(g_fg_boundary, te); + int obc0 = fg_block_start(band_grid, tb); + int obc1 = fg_block_start(band_grid, te); /* Fill spans come from the grid. The strip is full input width * because the raster API reads whole rows, so columns are not * cropped. */ - fg_span(g_fg_boundary, obr0, obr1, obc0, obc1, &imin, &imax); + fg_span(band_grid, obr0, obr1, obc0, obc1, &imin, &imax); int strip_rows = imax - imin + 1; /* Serial strip load, since a single fd makes get_row unsafe to * share. An empty tile with strip_rows at or below zero projects * outside the input and is not read, its cells become NULL through - * interpolate_strip's out-of-map path, and the window is + * strip_nearest's out-of-map path, and the window is * invalidated so the next band re-reads in full. */ void *strip = NULL; if (strip_rows > 0) { @@ -1233,8 +1233,8 @@ int main(int argc, char **argv) t_write += rproj_wtime() - tw; } G_free(y_center); - if (g_fg_boundary) - fg_free(g_fg_boundary); + if (band_grid) + fg_free(band_grid); /* Single free site for the rolling window. Normal completion and both * fallback_done bails converge here, so one free covers every path. win is * NULL when a bail fired before any band allocated it. */ diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index 76dc9ff00d3..3a142ee5f2e 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -46,7 +46,7 @@ enum OutputFormat { PLAIN, SHELL, JSON }; struct pole_set { int n; /* active poles, 0 to 2 */ double ox[2], oy[2]; /* pole coordinates in the output CRS */ - double ri[2]; /* pole input row index */ + double pole_row[2]; /* pole input row index */ }; /* Footprint grid of input row spans for the output map, built in footprint.c. From 92e6f03025f4c243182010f753afac96c77f2184 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Sun, 2 Aug 2026 23:56:47 -0700 Subject: [PATCH 30/32] r.proj: shorten and clarify the comments Shorten the longer comments to plain descriptions of what the code does and drop the internal jargon. No code changes. --- lib/proj/do_proj.c | 7 ++- raster/r.proj/interp_strip.c | 24 ++++----- raster/r.proj/main.c | 95 +++++++++++++----------------------- raster/r.proj/r.proj.h | 11 ++--- 4 files changed, 51 insertions(+), 86 deletions(-) diff --git a/lib/proj/do_proj.c b/lib/proj/do_proj.c index 795c6a92f7d..e3773007ecf 100644 --- a/lib/proj/do_proj.c +++ b/lib/proj/do_proj.c @@ -1423,7 +1423,7 @@ int pj_do_transform(int count, double *x, double *y, double *h, * into a new private context. Release it with GPJ_free_transform_clone(). * * Safe to call concurrently from multiple threads with the same \p src, - * provided \p src is not modified during the calls: each call clones into its + * provided \p src is not modified during the calls. Each call clones into its * own new context and touches no shared mutable state. * * \param src source transform (as set up by GPJ_init_transform()) @@ -1433,9 +1433,8 @@ void GPJ_clone_transform(const struct pj_info *src, struct gpj_transform_clone *clone) { clone->ctx = proj_context_create(); - /* r.proj calls this in each worker thread, so a fatal here ends the whole - * process from inside the parallel region. That is intended: a clone - * failure leaves the thread with no usable transform. */ + /* A failed context leaves the thread with no usable transform, so this + * aborts the run. */ if (clone->ctx == NULL) G_fatal_error(_("proj_context_create() failed for a per-thread " "transform clone")); diff --git a/raster/r.proj/interp_strip.c b/raster/r.proj/interp_strip.c index c1a18899286..193f8258814 100644 --- a/raster/r.proj/interp_strip.c +++ b/raster/r.proj/interp_strip.c @@ -1,9 +1,7 @@ /* - * interp_strip.c - strip-based interpolation kernels for the banded r.proj - * compute path. These mirror the cache-based kernels (bilinear.c, cubic.c, - * lanczos.c and their _f variants) but read an in-RAM FCELL band strip - * holding input rows [imin, imax] instead of the readcell block cache. - * Nearest is handled by interpolate_strip() in main.c and is not duplicated. + * interp_strip.c - strip versions of the resampling methods. They read the + * input rows from a strip held in memory as floats, instead of the block + * cache. Nearest is handled by strip_nearest() in main.c. */ #include @@ -12,14 +10,9 @@ #include #include "r.proj.h" -/* Read one FCELL from the band strip. The strip holds full-width input rows - * [imin, imax] contiguously; input row r maps to strip row (r - imin), the same - * addressing as interpolate_strip(). Every read is guarded by the same - * under-size tripwire as interpolate_strip: a stencil row inside the input map - * but outside the loaded strip means a sizing/indexing bug, so fail loudly - * rather than read out of bounds. Each kernel runs its full-map bounds check - * first (setting NULL for out-of-map stencils), so this tripwire only ever - * fires on a bug. */ +/* Read one value from the strip. Input row r maps to strip row (r - imin). A + * row inside the input map but outside the loaded strip is a bug, so fail + * rather than read out of bounds. */ static inline FCELL strip_val(const void *strip, int r, int c, int imin, int imax, int cols) { @@ -41,8 +34,9 @@ void strip_bilinear(void *strip, void *obufptr, int cell_type, double col_idx, row = (int)floor(row_idx - 0.5); col = (int)floor(col_idx - 0.5); - /* Full-map bounds check runs before any strip read: an out-of-map stencil - * sets NULL and returns, so strip_val is never reached out of range. */ + /* Full-map bounds check runs before any strip read. A sample outside the + * input map is set to NULL and returned, so strip_val is never asked for a + * row outside the strip. */ if (row < 0 || row + 1 >= incellhd->rows || col < 0 || col + 1 >= incellhd->cols) { Rast_set_null_value(obufptr, 1, cell_type); diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index 044bbe80cbc..d9b0934ad57 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -115,11 +115,8 @@ static void quantize_cell_row(void *row, int cols, int cell_type) } } -/* Nearest-neighbor read from an in-RAM strip holding input rows [imin, imax]. - * The col_idx and row_idx values are full-map indices and the strip is - * addressed relative to imin. A sample that lands inside the input map but - * outside the loaded strip means the band was under-sized, which the guard - * below catches. */ +/* Nearest read from the in-RAM input strip covering rows imin to imax. Indices + * are full-map and addressed relative to imin. */ static void strip_nearest(void *strip, void *obufptr, int cell_type, double col_idx, double row_idx, struct Cell_head *incellhd, int imin, int imax) @@ -134,9 +131,8 @@ static void strip_nearest(void *strip, void *obufptr, int cell_type, return; } - /* The band footprint was under-sized when a needed input row lies inside - * the input map but outside the loaded strip, so it fails loudly rather - * than emit a wrong NULL. */ + /* Fail loudly when a needed input row is inside the map but outside the + * loaded strip. */ if (r < imin || r > imax) G_fatal_error(_("Band strip under-sized: input row %d outside loaded " "range [%d, %d] at column %d"), @@ -148,22 +144,17 @@ static void strip_nearest(void *strip, void *obufptr, int cell_type, memcpy(obufptr, src, cell_size); } -/* Strip kernels in the same order as menu[], so slot i is the strip counterpart - * of menu[i].method. Slot 0 is nearest above and slots 1 to 6 come from - * interp_strip.c. */ +/* Strip kernels in menu[] order, so slot i matches menu[i].method. */ static const strip_func strip_kernels[] = { strip_nearest, strip_bilinear, strip_cubic, strip_lanczos, strip_bilinear_f, strip_cubic_f, strip_lanczos_f}; -/* Footprint grid used for comparison and the counters printed at the end. */ +/* Grid that sizes band heights and column tiles. */ static struct footprint_grid *band_grid = NULL; -/* Serial tile-cache fallback for the oblique and large-halo corner. When even - * one output row's full-width strip busts the cap, this finishes the run from - * row obr0 with the classic readcell cache and CVAL kernels, exactly as serial - * r.proj does. It stays serial because get_block mutates shared cache state, - * and since the banded path already wrote the earlier rows the result matches - * a pure serial run. */ +/* Serial tile-cache path for output rows whose input footprint is too tall to + * band. Finishes the run from row obr0 with the readcell cache so it matches + * serial r.proj. */ static void fallback_serial_cache(int fdi, int fdo, int cell_type, int method, const struct pj_info *oproj, const struct pj_info *iproj, @@ -203,9 +194,8 @@ fallback_serial_cache(int fdi, int fdo, int cell_type, int method, G_free(obuffer); } -/* Write the deferred band, if any, in order and release it. Shared by the last - * band and the fallback bails so every path writes the deferred band the same - * way. */ +/* Write the previous band's output rows that were held for the overlapped + * write, then free the buffer. Returns when nothing is held. */ static void flush_pending_band(int fdo, int cell_type, int cols, int cell_size, void **pending, int r0, int r1) { @@ -220,9 +210,7 @@ static void flush_pending_band(int fdo, int cell_type, int cols, int cell_size, *pending = NULL; } -/* Thread count for compute and write overlap, where nprocs above zero - * overrides OMP_NUM_THREADS. Set before the fit search so the write overlap's - * two reserved output buffers match the band sizing. */ +/* Return the compute thread count, with nprocs overriding OMP_NUM_THREADS. */ static int compute_nprocs(struct Option *nprocs) { return G_set_omp_num_threads(nprocs); @@ -571,7 +559,7 @@ int main(int argc, char **argv) if (G_verbose() > G_verbose_std()) pj_print_proj_params(&iproj, &oproj); - /* this call causes r.proj to read the entire map into memory */ + /* Read the input map's rows, columns, resolution, and bounds. */ Rast_get_cellhd(inmap->answer, setname, &incellhd); if (G_projection() == PROJECTION_XY) @@ -815,9 +803,8 @@ int main(int argc, char **argv) G_message(_("NS-res: %f"), outcellhd.ns_res); G_message(" "); - /* Open the input map in the input env. Banding loads only per-band input - * strips rather than the whole map, so fdi stays open across the band - * loop. */ + /* Open the input map in the input env. fdi stays open across the band loop + * because each band reads only its own input strip. */ G_switch_env(); Rast_set_input_window(&incellhd); fdi = Rast_open_old(inmap->answer, setname); @@ -826,15 +813,10 @@ int main(int argc, char **argv) cell_type = FCELL_TYPE; cell_size = Rast_cell_size(cell_type); - /* The read-thread count is decided here in the input env so the mask guard - * checks the source mapset's mask. Rast_disable_omp_on_mask returns 1 and - * forces serial under a mask or without OpenMP, and leaves the count - * untouched otherwise (lib/raster/mask_info.c lines 226-231). When - * read_nprocs is above one, each thread opens its own fresh fd and fdi - * serves only the serial fallback. Concurrent fds on the same map across - * locations follow the r.neighbors in_fd[] precedent, where each fd carries - * its own cur_row, data, and data_fd. */ - /* Runs before the fit search. See compute_nprocs(). */ + /* Pick the read-thread count in the input env so the mask guard checks the + * source mapset's mask. Rast_disable_omp_on_mask forces serial under a mask + * or without OpenMP. Above one thread each thread opens its own fd and fdi + * serves only the serial fallback. */ int want_nprocs = compute_nprocs(nprocs); int read_nprocs = Rast_disable_omp_on_mask(want_nprocs); int *fd_read = NULL; @@ -869,19 +851,16 @@ int main(int argc, char **argv) * memory by the cap rather than the whole input map. */ double cap_mb = atof(memory->answer); size_t cap_bytes = (size_t)(cap_mb * 1024.0 * 1024.0); - /* Under write_overlap the overlapped writes run inside the compute region, - * so their time falls in t_compute. t_write then covers only the - * non-overlapped writes, which are the last band's flush and every band at - * one thread. The fallback bail flushes are untimed. */ + /* Output write time that overlap the compute time are counted in the + * compute time. The write time only counts the writes that run on their + * own. The fallback path is not timed. */ double t_size = 0.0, t_fill = 0.0, t_compute = 0.0, t_write = 0.0; int n_bands = 0; int max_tiles = 1; /* most column tiles used by any single band */ - /* Output-row center northings from the serial recurrence, starting at north - * minus ns_res/2 and subtracting ns_res per row. The direct form differs by - * up to one ULP when ns_res is not exactly representable, so the recurrence - * is kept to stay bitwise identical to serial. The fill loop and the sizing - * walk share these values. */ + /* Holds the north-to-south coordinate of the center of each output row. It + * starts at the top edge and steps down one cell per row. The step uses + * repeated subtraction to match serial r.proj exactly. */ double *y_center = G_malloc((size_t)outcellhd.rows * sizeof(double)); { double yc = outcellhd.north - (outcellhd.ns_res / 2); @@ -891,16 +870,11 @@ int main(int argc, char **argv) } } - /* Pole footprint fix. A tile that projects onto a geographic pole has an - * input-row extremum the perimeter walk misses. This happens when the pole - * lies inside the input map, and also when the pole is outside the input's - * latitude coverage but still projects into the output frame, as with a - * pole-centered frame reading an input truncated below the pole, where the - * highest reachable input latitude is the input's own edge row. So it - * projects both poles for lat/lon input and folds in the pole's input row - * clamped to [0, rows-1]. The point-in-rect test keeps this a no-op for - * frames that image no pole, and a transform failure or non-finite result - * skips the pole and leaves the under-size guard as the backstop. */ + /* For a lat/lon input, project the north and south poles into the output + * and record each pole's input row, clamped to the map. A pole is the + * highest or lowest latitude, which the column samples can step over, so + * keeping its row makes sure the loaded strip reaches it. Does nothing when + * no pole lands inside the output map. */ struct pole_set poles; poles.n = 0; @@ -942,11 +916,10 @@ int main(int argc, char **argv) unsigned char *win = NULL; size_t win_cap = 0; int win_imin = 0, win_imax = -1; - /* Output double-buffer predicate. The compute region runs want_nprocs - * threads rather than the masked read_nprocs, so overlap needs more than - * one compute thread. out_mult reserves two output bands in the fit search - * on the same flag the writer uses, so the budget and the writer stay in - * step. */ + /* Turn on output double buffering when more than one compute thread runs, + * so one thread can write the previous band while the next one computes. + * out_mult then reserves two output bands so the memory budget matches the + * writer. */ int write_overlap = want_nprocs > 1; int out_mult = write_overlap ? 2 : 1; /* Previous band's output buffer, written by one thread while the next band diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index 3a142ee5f2e..c1c359d9d46 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -27,9 +27,8 @@ struct cache { typedef void (*func)(struct cache *, void *, int, double, double, struct Cell_head *); -/* Strip-based interpolation kernels (interp_strip.c) for the banded compute - * path read an in-RAM FCELL strip holding input rows [imin, imax] instead of - * the readcell block cache, so they take imin/imax in place of struct cache. */ +/* Strip interpolation kernels (interp_strip.c) read the input rows imin to imax + * from a strip held in memory, and take imin/imax in place of struct cache. */ typedef void (*strip_func)(void *, void *, int, double, double, struct Cell_head *, int, int); @@ -41,8 +40,8 @@ struct menu { enum OutputFormat { PLAIN, SHELL, JSON }; -/* Geographic poles that fall inside the input map, folded into the input row - * span of the tile that contains them. Empty when no pole is in frame. */ +/* Geographic poles that land inside the output map, each stored as its output + * position and its input row. Empty when no pole lands inside. */ struct pole_set { int n; /* active poles, 0 to 2 */ double ox[2], oy[2]; /* pole coordinates in the output CRS */ @@ -102,7 +101,7 @@ extern void p_lanczos(struct cache *, void *, int, double, double, extern void p_lanczos_f(struct cache *, void *, int, double, double, struct Cell_head *); -/* interp_strip.c - strip variants for the banded compute path */ +/* interp_strip.c - strip versions of the resampling methods */ extern void strip_bilinear(void *, void *, int, double, double, struct Cell_head *, int, int); extern void strip_cubic(void *, void *, int, double, double, struct Cell_head *, From fe6602c8737b1e6f8d2c0b20f4b22c61f7a40232 Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Tue, 4 Aug 2026 11:51:43 -0700 Subject: [PATCH 31/32] r.proj: co-size band heights and tile widths from the footprint grid The band loop co-sizes each band's height and tile width from the footprint grid. It grows the height while a full-width band fits the memory cap and turns to whole column tiles only when even one full-width row does not fit, so an output row whose columns project across a wide span of input rows is read in tall bands instead of one row at a time. The output values are identical to the serial result. --- raster/r.proj/footprint.c | 112 ++++++++++++++++++++------------------ raster/r.proj/main.c | 94 ++++++++++++++------------------ raster/r.proj/r.proj.h | 10 ++-- 3 files changed, 105 insertions(+), 111 deletions(-) diff --git a/raster/r.proj/footprint.c b/raster/r.proj/footprint.c index 0b79b0347a5..f8c77606032 100644 --- a/raster/r.proj/footprint.c +++ b/raster/r.proj/footprint.c @@ -187,51 +187,6 @@ void fg_span(const struct footprint_grid *g, int obr0, int obr1, int obc0, *imax = hi; } -/* Find the tallest band at obr0 whose strip and output still fit the cap, and - * never return less than one row. */ -int fg_band_height(const struct footprint_grid *g, int obr0, size_t cap_bytes, - int out_mult, int cell_size, int in_cols) -{ - double rmin = DBL_MAX, rmax = -DBL_MAX; - int max_h = g->grows - obr0, accepted = 1, h, b; - - for (h = 0; h < max_h; h++) { - int r = obr0 + h, strip_rows; - size_t strip_bytes, out_bytes; - - for (b = 0; b < g->nb; b++) { - const struct fg_cell *cell = &g->cell[(size_t)r * g->nb + b]; - - if (cell->rmax < cell->rmin) - continue; - if (cell->rmin < rmin) - rmin = cell->rmin; - if (cell->rmax > rmax) - rmax = cell->rmax; - } - if (rmax < rmin) { - strip_rows = 0; - } - else { - int lo = (int)floor(rmin) - 2; - int hi = (int)floor(rmax) + 2; - - if (lo < 0) - lo = 0; - if (hi > g->irows - 1) - hi = g->irows - 1; - strip_rows = hi - lo + 1; - } - strip_bytes = - strip_rows > 0 ? (size_t)strip_rows * in_cols * cell_size : 0; - out_bytes = (size_t)(h + 1) * g->ocols * cell_size; - if (!(strip_bytes + out_mult * out_bytes <= cap_bytes)) - break; - accepted = h + 1; - } - return accepted; -} - /* Number of column blocks in the grid. */ int fg_num_blocks(const struct footprint_grid *g) { @@ -262,17 +217,15 @@ static int worst_ktile_rows(const struct footprint_grid *g, int obr0, int obr1, return worst; } -/* Widest tile in whole blocks whose worst strip and the output still fit the - * cap, or zero when even one block per tile busts. Reports the worst single - * block strip for the caller message. */ -int fg_tile_blocks(const struct footprint_grid *g, int obr0, int obr1, - size_t cap_bytes, int out_mult, int cell_size, int in_cols, - int *worst_block_rows) +/* Widest tile in whole blocks whose worst strip and the output fit the cap, or + * zero when even one block per tile busts. */ +static int tile_blocks_for_band(const struct footprint_grid *g, int obr0, + int obr1, size_t cap_bytes, int out_mult, + int cell_size, int in_cols) { size_t out_bytes = (size_t)(obr1 - obr0) * g->ocols * cell_size; int k; - *worst_block_rows = worst_ktile_rows(g, obr0, obr1, 1); if (out_mult * out_bytes > cap_bytes) return 0; for (k = g->nb; k >= 1; k--) { @@ -286,6 +239,61 @@ int fg_tile_blocks(const struct footprint_grid *g, int obr0, int obr1, return 0; } +/* Grows the band height by doubling, preferring full-width bands and tiling + * only when even one full-width row busts the cap, and takes the last fitting + * height with its widest tile. Reports the finest tile strip the fallback + * message needs and returns zero when even one tiled row busts. */ +int fg_band_geometry(const struct footprint_grid *g, int obr0, size_t cap_bytes, + int out_mult, int cell_size, int in_cols, + int *tile_blocks_out, int *worst_block_rows) +{ + int remaining = g->grows - obr0; + int best_h = 0, best_k = 0, h_cand; + + *worst_block_rows = worst_ktile_rows(g, obr0, obr0 + 1, 1); + + /* Prefer full-width bands, growing the height while the whole row still + * fits the cap as a single tile. */ + for (h_cand = 1;; h_cand *= 2) { + int h = h_cand < remaining ? h_cand : remaining; + int worst = worst_ktile_rows(g, obr0, obr0 + h, g->nb); + size_t strip_bytes = + worst > 0 ? (size_t)worst * in_cols * cell_size : 0; + size_t out_bytes = (size_t)h * g->ocols * cell_size; + + if (strip_bytes + out_mult * out_bytes > cap_bytes) + break; + best_h = h; + if (h == remaining) + break; + } + if (best_h > 0) { + *tile_blocks_out = g->nb; + return best_h; + } + + /* One full-width row busts the cap, so grow while the exhaustive scan finds + * any fitting whole-block tile. */ + for (h_cand = 1;; h_cand *= 2) { + int h = h_cand < remaining ? h_cand : remaining; + int k = tile_blocks_for_band(g, obr0, obr0 + h, cap_bytes, out_mult, + cell_size, in_cols); + + if (k == 0) + break; + best_h = h; + best_k = k; + if (h == remaining) + break; + } + if (best_h == 0) { + *tile_blocks_out = 0; + return 0; + } + *tile_blocks_out = best_k; + return best_h; +} + /* Widens every non-empty cell by the sampling margin. */ void fg_apply_sampling_margin(struct footprint_grid *g) { diff --git a/raster/r.proj/main.c b/raster/r.proj/main.c index d9b0934ad57..883cc9d70d7 100644 --- a/raster/r.proj/main.c +++ b/raster/r.proj/main.c @@ -929,64 +929,52 @@ int main(int argc, char **argv) int pending_r0 = 0, pending_r1 = 0; int obr0 = 0; while (obr0 < outcellhd.rows) { - /* Size this band. Take the tallest full-width band that fits, and when - * even one full-width row does not fit split it into whole column - * blocks and take the widest tile that fits. */ + /* Co-size this band's height and tile width from the grid. A zero + * height means even one row busts the cap so the run finishes from obr0 + * on the serial tile-cache path. */ double ts = rproj_wtime(); int imin = 0, imax = -1; + int tile_blocks = 0, worst_block_rows = 0; int band_orows = force_tilecache - ? 1 - : fg_band_height(band_grid, obr0, cap_bytes, out_mult, - cell_size, incellhd.cols); - int tile_blocks = fg_num_blocks(band_grid); - if (band_orows == 1) { - int worst_block_rows = 0; - - tile_blocks = - force_tilecache - ? 0 - : fg_tile_blocks(band_grid, obr0, obr0 + band_orows, - cap_bytes, out_mult, cell_size, - incellhd.cols, &worst_block_rows); - if (tile_blocks == 0) { - /* Even the finest tiling busts the cap, so finish from obr0 on - * the serial tile-cache path. */ - if (force_tilecache) { - G_warning( - _("R_PROJ_FORCE_TILECACHE is set: taking the serial " - "tile-cache path for all output rows (testing " - "override).")); - } - else { - size_t out1 = (size_t)outcellhd.cols * cell_size; - size_t strip_bytes = worst_block_rows > 0 - ? (size_t)worst_block_rows * - incellhd.cols * cell_size - : 0; - int needed_mb = (int)ceil((double)(strip_bytes + out1) / - (1024.0 * 1024.0)) + - 1; - G_warning( - _("Memory cap (%.1f MB) is below what one output row " - "needs (input footprint %d rows, %.1f MB). Falling " - "back to the serial tile-cache path for output rows " - "%d-%d; this path is slower. Raise memory= to at " - "least %d MB to use the parallel path."), - cap_mb, worst_block_rows, - (double)(strip_bytes + out1) / (1024.0 * 1024.0), obr0, - outcellhd.rows - 1, needed_mb); - } - /* Flush the deferred band before the fallback writes from obr0 - * in order. */ - flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, - &pending_out, pending_r0, pending_r1); - fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, - &iproj, &tproj, &incellhd, &outcellhd, - y_center, obr0, memory->answer); - used_fallback = 1; - goto fallback_done; + ? 0 + : fg_band_geometry(band_grid, obr0, cap_bytes, out_mult, + cell_size, incellhd.cols, &tile_blocks, + &worst_block_rows); + if (band_orows == 0) { + if (force_tilecache) { + G_warning(_("R_PROJ_FORCE_TILECACHE is set: taking the serial " + "tile-cache path for all output rows (testing " + "override).")); + } + else { + size_t out1 = (size_t)outcellhd.cols * cell_size; + size_t strip_bytes = + worst_block_rows > 0 + ? (size_t)worst_block_rows * incellhd.cols * cell_size + : 0; + int needed_mb = (int)ceil((double)(strip_bytes + out1) / + (1024.0 * 1024.0)) + + 1; + G_warning( + _("Memory cap (%.1f MB) is below what one output row " + "needs (input footprint %d rows, %.1f MB). Falling " + "back to the serial tile-cache path for output rows " + "%d-%d; this path is slower. Raise memory= to at " + "least %d MB to use the parallel path."), + cap_mb, worst_block_rows, + (double)(strip_bytes + out1) / (1024.0 * 1024.0), obr0, + outcellhd.rows - 1, needed_mb); } + /* Flush the deferred band before the fallback writes from obr0 in + * order. */ + flush_pending_band(fdo, cell_type, outcellhd.cols, cell_size, + &pending_out, pending_r0, pending_r1); + fallback_serial_cache(fdi, fdo, cell_type, method, &oproj, &iproj, + &tproj, &incellhd, &outcellhd, y_center, obr0, + memory->answer); + used_fallback = 1; + goto fallback_done; } t_size += rproj_wtime() - ts; diff --git a/raster/r.proj/r.proj.h b/raster/r.proj/r.proj.h index c1c359d9d46..5bd766a1b76 100644 --- a/raster/r.proj/r.proj.h +++ b/raster/r.proj/r.proj.h @@ -58,14 +58,12 @@ fg_build(const struct Cell_head *ohd, const struct Cell_head *ihd, const struct pole_set *poles); extern void fg_span(const struct footprint_grid *g, int obr0, int obr1, int obc0, int obc1, int *imin, int *imax); -extern int fg_band_height(const struct footprint_grid *g, int obr0, - size_t cap_bytes, int out_mult, int cell_size, - int in_cols); extern int fg_num_blocks(const struct footprint_grid *g); extern int fg_block_start(const struct footprint_grid *g, int b); -extern int fg_tile_blocks(const struct footprint_grid *g, int obr0, int obr1, - size_t cap_bytes, int out_mult, int cell_size, - int in_cols, int *worst_block_rows); +extern int fg_band_geometry(const struct footprint_grid *g, int obr0, + size_t cap_bytes, int out_mult, int cell_size, + int in_cols, int *tile_blocks_out, + int *worst_block_rows); extern void fg_apply_sampling_margin(struct footprint_grid *g); extern void fg_free(struct footprint_grid *g); From 6f6652f0639cf080a83e00bd8bfd4aa556f0222f Mon Sep 17 00:00:00 2001 From: Kaushik Raja Date: Wed, 5 Aug 2026 23:27:09 -0700 Subject: [PATCH 32/32] r.proj: shorten the test and benchmark comments Shortens the docstrings and comments in the tests and the benchmark script. --- raster/r.proj/benchmark/benchmark_r_proj.py | 23 ++++----- raster/r.proj/tests/conftest.py | 28 +---------- raster/r.proj/tests/r_proj_parallel_test.py | 52 ++++++--------------- 3 files changed, 23 insertions(+), 80 deletions(-) diff --git a/raster/r.proj/benchmark/benchmark_r_proj.py b/raster/r.proj/benchmark/benchmark_r_proj.py index acbd77abb6e..88cf48468ae 100644 --- a/raster/r.proj/benchmark/benchmark_r_proj.py +++ b/raster/r.proj/benchmark/benchmark_r_proj.py @@ -1,14 +1,9 @@ -"""Benchmarking of r.proj thread scaling -raster (2D) - -This follows the r.param.scale benchmark structure, sweeping raster size at a -fixed memory and then memory at a fixed raster size, and plotting the time, -speedup, and efficiency metrics with grass.benchmark. r.proj sweeps its compute -thread count through the nprocs= option. Each cell generates a source raster in -an EPSG:4326 project and reprojects it into EPSG:3857 in a temporary database, -so the script is self-contained. Run it with -grass --exec python benchmark_r_proj.py or from any GRASS session. -""" +"""This is a benchmark script for r.proj thread scaling with grass.benchmark. + +This script sweeps through a raster size at a fixed memory setting, and then +memory at a fixed size. It then plots time, speedup, and efficiency. Creates +its own source raster and projects, so it runs standalone with +grass --exec python benchmark_r_proj.py.""" import os import tempfile @@ -104,10 +99,8 @@ def benchmark(gisdbase, size, memory, label, results): def generate_input(gisdbase, size): - """Generate the size by size source raster in the EPSG:4326 project, - mirroring the r.param.scale benchmark by trying r.surf.fractal and falling - back to r.random.surface when fractal is unavailable, for example in a build - without FFTW.""" + """Generate the source raster in the EPSG:4326 project. Uses + r.surf.fractal, or r.random.surface when FFTW is unavailable.""" with gs.setup.init( os.path.join(gisdbase, SRC_PROJECT), env=os.environ.copy() ) as session: diff --git a/raster/r.proj/tests/conftest.py b/raster/r.proj/tests/conftest.py index c50290414cf..db5d8dfb930 100644 --- a/raster/r.proj/tests/conftest.py +++ b/raster/r.proj/tests/conftest.py @@ -1,30 +1,6 @@ -"""Fixtures for the r.proj parallel-correctness pytest. +"""This is a source project with two small rasters and two destination sessions.""" -Builds one GISDBASE holding an EPSG:4326 source project with two small -generated input rasters, plus EPSG:3857 and EPSG:3413 (north polar -stereographic) destination projects. r.proj reprojects from the source -into the active destination session; the tests compare the module's own -serial and parallel runs. - -The input is integer CELL with values well below 2^24 -(row()*100 + col() + (row()*row()+col()*col())%13, max ~5058), so it survives -a float32 round-trip losslessly. This is deliberate: the forced tile-cache -path reads through the FCELL readcell cache while the banded nearest path -reads the native type, so only a float32-exact input keeps the forced-fallback -bitwise assert valid (a DCELL input would diverge by float32 quantization -alone). The (row()*row()+col()*col())%13 term gives the surface enough -curvature that bilinear and bicubic interpolation diverge past the reference -test's rel=1e-7 tolerance (a linear ramp, or a milder term, leaves their -statistics identical or within tolerance), which the method reference test -relies on to catch an _f-kernel dispatch swap. Values depend on -grid position only (no trig, no random), so they are bit-identical across -platforms and resolutions. Both rasters are 50x50 to stay well under the CI -time budget. -""" - -# Duplicated from the companion test-split PR (branch fix-rproj-tests), -# which owns this file alongside the method reference tests. Drop this copy -# when that PR merges into main; the fixtures are identical. +# Copied from the test PR. Drop this file when that PR merges. import os diff --git a/raster/r.proj/tests/r_proj_parallel_test.py b/raster/r.proj/tests/r_proj_parallel_test.py index 02b4dc89630..0b49291d011 100644 --- a/raster/r.proj/tests/r_proj_parallel_test.py +++ b/raster/r.proj/tests/r_proj_parallel_test.py @@ -1,23 +1,6 @@ -"""Parallel-correctness tests for r.proj. - -r.proj takes a nprocs= option that sets the compute thread count, so each run -below passes nprocs= for that run, and the fallback test also sets -R_PROJ_FORCE_TILECACHE on its own env copy. Nothing shared is mutated, so the -serial and parallel runs of a test cannot leak thread or path state into each -other. - -The baseline is the module's own single-thread run at nprocs=1 rather than an -external serial binary. The question these tests answer is whether adding -threads, or taking the tile-cache fallback, changes the output of this same -binary. That comparison is exact and reproducible in CI where an external -oracle would not be. - -Nearest is asserted bitwise with an absolute diff max of zero. Bilinear is -asserted bitwise too because each output cell is interpolated independently in -a fixed operation order, so threading does not reorder its arithmetic. The -epsilon-1e-6 fallback from the proposal may be invoked only on an actual CI -reordering failure, naming the platform that showed it. -""" +"""Parallel correctness tests for r.proj. Each test compares the +module's own nprocs=1 run against a multithreaded run, and the fallback +test forces the tile cache path.""" import grass.script as gs @@ -36,10 +19,7 @@ def _env(session, **overrides): def _set_region_from_source(env, input_raster, method): - """Set the output region to r.proj's suggested bounds for the input. - - r.proj -g prints the whole region as space-separated key=value pairs on - one line, so split on whitespace first, then on '='.""" + """Set the output region to r.proj's suggested bounds for the input.""" text = gs.read_command( "r.proj", project=SRC_PROJECT, @@ -82,8 +62,7 @@ def _stats(env, raster): def _assert_bitwise_identical(env, a, b, diff): - """Assert a and b are bitwise identical: equal counts, equal null - pattern, and a zero-valued absolute difference over a non-empty map.""" + """Check a and b are bitwise identical and have the same null cells.""" gs.run_command( "r.mapcalc", expression=f"{diff} = abs({a} - {b})", overwrite=True, env=env ) @@ -97,11 +76,9 @@ def _assert_bitwise_identical(env, a, b, diff): def test_bilinear_parallel_matches_serial(session_3857): - """Bilinear: parallel output must equal the serial output bitwise. - - A dispatch-liveness guard runs first: bilinear must differ from nearest - on the same frame, so a silent fallback to nearest cannot make the - identity assert pass vacuously (the Bug A regression guard).""" + """Bilinear method needs to match serial bitwise. It first checks that + bilinear and nearest outputs differ, so a silent fallback to nearest + cannot happen.""" session = session_3857 base = _env(session) _set_region_from_source(base, INPUT_MID, "bilinear") @@ -123,8 +100,8 @@ def test_bilinear_parallel_matches_serial(session_3857): def test_nearest_memory_banding(session_3857): - """Nearest with a constrained memory cap (memory=5, OMP=4) must match the - default-memory serial run bitwise, exercising band sizing at a small cap.""" + """The nearest method at a small memory cap (memory=5, nprocs=4) has to + match the default memory serial run bitwise.""" session = session_3857 base = _env(session) _set_region_from_source(base, INPUT_MID, "nearest") @@ -135,8 +112,7 @@ def test_nearest_memory_banding(session_3857): def test_pole_nearest_parallel_matches_serial(session_pole): - """Nearest into a frame centered on the north pole: the warped access - pattern near the pole must still give bitwise-identical parallel output.""" + """Nearest at the north pole matches serial bitwise.""" session = session_pole base = _env(session) # Fixed 1200 km box centered on the pole (EPSG:3413 meters), 50x50. @@ -157,10 +133,8 @@ def test_pole_nearest_parallel_matches_serial(session_pole): def test_forced_fallback_matches_banded(session_3857): - """The forced serial tile-cache path must equal the banded parallel path - bitwise. R_PROJ_FORCE_TILECACHE=1 takes the readcell tile-cache route - (a different algorithm), so this is a cross-path check, not just a - thread-count one.""" + """Forcing the tile cache with R_PROJ_FORCE_TILECACHE=1 gives the same + output as the banded path.""" session = session_3857 base = _env(session) _set_region_from_source(base, INPUT_MID, "nearest")