Summary
isce3::geocode::geocodeSlc (the kernel behind Sentinel-1 CSLC generation in COMPASS, and SLC-domain geocoding generally) has no CUDA backend. Profiling on a real Sentinel-1 IW burst (~16 s wall per burst on a 16-core host) shows its time is concentrated in the per-pixel geo2rdr Newton solve — functionality that isce3::cuda::geocode::Geocode already implements on-device and
that the production workflow code (nisar/workflows/geocode_insar.py) already drives.
This issue proposes building a CUDA geocodeSlc backend on the existing cuda::geocode::Geocode infrastructure rather than writing a parallel CUDA implementation from scratch. Whether that takes the form of an extension of Geocode, composition, or a shared lower-level backend is a design question I'd like maintainer input on (below).
An end-to-end trial of the existing CUDA class on the same real burst supports feasibility: it reproduces CPU geometry and amplitude to within quantified tolerances and runs the shared portion ~5× faster than the CPU path on a consumer GPU. I am prepared to implement this and want to align on scope and design constraints first.
Key results
| measurement |
result |
geo2rdr share of the geocodeSlc call (py-spy ×2 + perf) |
69–77 % |
isce3::core::Orbit::interpolate share of whole-run user cycles |
~54 % |
| Existing CUDA Geocode vs CPU parity, RTX 5080, 1 burst (n=5) |
5.1× |
| Same trial, A100, 9-burst IW2 aggregate (n=3/burst; CPU host-shared) |
6.7× warm / 5.7× batch |
| Full frame, A100 GPU sequential batch (27 bursts, 2.04 Gpx) |
62.0 s wall |
| Valid-pixel masks, RTX 1 burst / A100 9 bursts |
identical (42,841,555 px / all 9 bursts) |
| Amplitude agreement, RTX / A100 |
r ≈ 1.0; max rel. err 3.0e-2 / worst-burst 4.3e-2 |
All numbers are hardware- and workload-specific; measurement protocols, fairness notes, and caveats are in the collapsed sections
below. Full provenance: RTX 5080 evidence bundle ·
A100 evidence bundle.
Motivation: where the CPU time actually goes (profiling methodology and attribution)
Workload: one Sentinel-1 IW3 burst (Boso, Japan), geogrid 3225 × 20939 (~67.5 Mpx), biquintic DEM interpolation, SINC SLC interpolation.
Host: Intel Core Ultra 9 285H (16 cores), 96 GB RAM, NVIDIA RTX 5080 16 GiB (measurement provenance and scripts:
RTX evidence bundle).
The geocodeSlc call takes ~16 s wall per burst (array mode, OpenMP, warm caches).
Three measurements agree on the attribution (the parallel py-spy run had worker-0 sampling bias and lag warnings and serves as a
consistency check; the OMP=1 and perf runs are the primary evidence):
| method |
geo2rdr share of the geocodeSlc call |
py-spy --native, parallel run (5079 samples) |
76.5 % |
py-spy --native, OMP_NUM_THREADS=1 (13884 samples) |
69 % |
perf record (cycles:u, all threads, 36562 samples) |
~77 % |
Sharper still: isce3::core::Orbit::interpolate alone is ~54 % of all user cycles of the whole run — the Newton solve's cost is
dominated by per-iteration Hermite orbit interpolation (detail::geo2rdr, Geo2Rdr.icc). The remainder of the kernel is
SINC interpolation (~20 % of single-thread kernel CPU) and DEM interpolation (~10 %). Note the different denominators: the 69-77 % figures are shares of the geocodeSlc call; the 54 % figure is a share of whole-run user cycles. perf stat: IPC ≈ 4.0, branch-miss < 0.7 % — consistent with compute-bound behavior (no cache/bandwidth counters were collected, so stated as "consistent with", not proven).
This is why a CUDA path is worth having: the hot loop is exactly the part cuda::geocode::Geocode already runs on-device (geoToRdrIndices kernel calls the device geo2rdr, all-double).
Feasibility evidence: existing CUDA Geocode on the same burst (trial protocol, timings, fp64, agreement)
To bound what reuse buys without writing any new CUDA code, I ran the existing isce3.cuda.geocode.Geocode (SINC mode) against the CPU isce3.geocode.geocode_slc on the same burst, same geogrid, same DEM, same geo2rdr parameters (delta_range matched to the CPU's hardcoded 1e-8).
Because the CUDA class lacks the SLC-specific phase steps (gap list below), the primary CPU comparator is a "parity" variant with
carrier/Doppler/flattening neutralized, so both sides do the same work; the production-semantics CPU run is also shown.
RTX 5080 (16 GiB, consumer, 1:64 fp64), n = 5, median (IQR):
| variant |
wall |
| CUDA Geocode, warm |
2.95 s (0.05) |
| CUDA Geocode, first call |
3.45 s |
| CPU geocode_slc, parity semantics |
15.04 s (0.27) |
| CPU geocode_slc, production semantics |
16.27 s (0.58) |
≈ 5.1× on the semantic-parity public-API comparison. Notes on fairness: the timed GPU region includes raster read, H2D, kernels,
D2H, and the raster API writes (final close/flush falls outside the timed region), while the CPU region is pure compute on preloaded
arrays — the asymmetry biases against the GPU. This is a production-shaped comparison, not a kernel microbenchmark.
Notably the device geo2rdr is all-double, yet the RTX 5080's weak nominal fp64 ratio (1:64) did not prevent the observed 5.1×
public-API speedup (GPU kernel counters were not collected, so no stronger claim is made). The SLC phase steps to be ported (carrier deramp/reramp, Doppler demodulation, flattening) also need double precision: a standalone kernel study measured ~10 rad unwrapped phase error in fp32 flattening at this slant-range scale, so fp64 is mandatory there. In that study (synthetic 1046×645 grid, flattening kernel only) the fp64 variant ran at 5.5× the fp32 one — far from the 64× nominal throughput gap — but the study covers neither the full phase chain nor burst-scale arrays, so per-step cost on real bursts remains to be measured during the port.
Agreement, measured through the actual code paths (a coordinate-encoded cf32 raster, so indices are quantized by float32):
- Valid-pixel masks identical: 42,841,555 valid pixels on both sides (≈63 % of the geogrid; the remainder lies outside the burst's
subswath validity region), zero mismatches.
- Radar-index agreement: range max |Δ| 0.0176 px (RMS 1.0e-4), azimuth max |Δ| 0.0011 px (RMS 7.1e-6); p99 = 0 on both axes, but that zero is quantization-dominated (cf32 encoding cannot represent sub-quantum differences), so max/RMS are the informative figures.
- Amplitude (carrier-invariant): Pearson r = 0.99999999999, mean relative error 2.0e-7, p99 = 0; max 3.0e-2 (tail localization
remains to be characterized).
- Deterministic: bit-exact across repeated runs at fixed
lines_per_block.
- Block-size sensitivity: changing
lines_per_block 200→100 alters 4 px of 42.8 M (0.093 ppm, max |Δ| 0.0204); the coordinate rasters differ at the same 4 px, locating the origin in the per-block geometry stage — consistent with per-block DEM interpolation or margin handling — rather than in the SLC interpolator. Left as an open question for the shared infrastructure.
Scale-up: datacenter GPU, full subswath and full frame (A100 9-burst / 27-burst results, VRAM scan)
To test whether the single-burst consumer-GPU result holds at production scale and on datacenter hardware, I repeated the trial on
an NVIDIA A100-SXM4-80GB (cloud instance; CPU = 2× AMD EPYC 7742, 128 cores, host-shared — CPU figures below carry that caveat; the RTX numbers above remain the controlled comparison).
Same isce3 source SHA, same trial semantics, lines_per_block = 200 for comparability. Full artifacts:
A100 evidence bundle.
All 9 IW2 VV bursts of the same Sentinel-1 acquisition (geogrids ~3480 × 23300 each, 734 Mpx total), n = 3 per burst:
| metric |
value |
| GPU, sum of per-burst warm medians |
23.8 s |
| GPU, sequential batch wall (cold start incl.) |
27.7 s |
| CPU parity semantics, sum of medians |
159.2 s |
| CPU production semantics, sum of medians |
166.1 s |
| speedup (parity, warm) |
6.7× |
| speedup (parity vs batch wall) |
5.7× |
The geometry/amplitude agreement replicates on every burst of the subswath: valid masks identical on all 9 (Jaccard 1.0), range index max |Δ| ≤ 0.0195 px, azimuth ≤ 0.0013 px, amplitude r ≈ 1.0 with worst per-burst max relative amplitude error 4.3e-2 (per-burst maxima 0.4-4.3e-2; same tail character as the single-burst run).
A full frame (27 VV bursts across IW1/IW2/IW3, 2.04 Gpx) geocodes in a 62.0 s sequential batch wall (~33 Mpx/s sustained), demonstrating the workload at frame scale.
The 80 GB card also allows a lines_per_block scan that doubles as memory evidence for the chip-buffer issue (collapsed section further below).
Wall time and peak device memory (out-of-process busy-poll NVML sampling, ~58k queries/s; coarser 20-50 ms samplers can miss or severely understate this transient — its >4 GiB dwell is only ~40-215 ms, and sampler cadence and process scheduling both contribute):
| lines_per_block |
wall (s) |
measured peak (MiB) |
chip model (GiB) |
| 200 |
2.37 |
4,849 |
2.84 |
| 1000 |
1.59 |
16,951 |
14.19 |
| 2000 |
1.49 |
32,049 |
28.39 |
Two conclusions: (a) baseline-subtracted peak scaling is consistent with the chip-buffer model (n_elem_out × 81 × 8 B, SINC_ONE² = 81; baseline 1,565 MiB) — at lines_per_block = 1000 (the runconfig-level default in adjacent workflows) the measured allocation footprint of 16,951 MiB exceeds a 16 GiB consumer card's total VRAM, so the OOM hazard on such cards is evidence-backed rather than estimated (no OOM was provoked on the RTX itself); (b) larger blocks do pay on large-memory cards (2.37 s → 1.49 s), with most of the gain already at 1000 — supporting memory-budget-based/adaptive block sizing rather than any fixed default. Outputs were bit-exact across all scanned block sizes on this burst; the 4-px block-size sensitivity observed on the RTX burst did not reproduce here.
Proposal: build on the cuda::geocode::Geocode infrastructure, don't fork it
The shared machinery is already there and driven by the production workflow code (geocode_insar.py): per-pixel
device geo2rdr with azimuth/range correction LUTs, projection + DEM interpolation, masked block machinery with the same interpolation margin as the CPU path, and a SINC interpolator with the same SINC_LEN/SINC_SUB and thrust::complex<float> support.
What geocodeSlc needs that the CUDA class lacks today (from a line-by-line diff of geocodeSlc.cpp vs Geocode.cu); this is the implementation scope:
- Carrier phase deramp before interpolation (per-radar-pixel az/rg carrier evaluation).
- Doppler demodulation inside the chip load + add-back after interpolation (no-op for zero-Doppler NISAR, required for S1
native-Doppler data).
- Carrier reramp + flattening, and the carrier/flattening phase output rasters (+
uncorrectedSRange option).
slicedRadarGrid masking.
- Subswath validity: CPU checks all chip corners, CUDA checks the chip center only.
- Geo-grid mask export (subswath labels) — currently internal-only.
- Carrier API: the CUDA class currently has no carrier arguments at all (its LUT2d surface covers native Doppler and correction LUTs) — a new carrier API is required. CPU
geocodeSlc accepts both LUT2d and Poly2d carriers; the existing device LUT2d plumbing may be reusable for the LUT2d case.
Semantic differences that should become explicit porting requirements with acceptance tests (they currently differ silently):
- Correction-LUT out-of-bounds: CPU skips the correction and keeps the pixel; CUDA masks the pixel invalid.
- Non-converged geo2rdr pixels: CPU leaves NaN indices; CUDA writes 0.0 + mask. (The porting requirement should target public-API mask/output semantics, not internal representation.)
geo2rdr tolerance: CPU hardcodes delta_range = 1e-8; CUDA takes it as a parameter.
Pre-existing sizing issue the port should address (SINC chip-buffer transient; OOM hazard on 16 GiB cards)
The SINC path preallocates n_elem_out × SINC_ONE² chips per block (self-described "VERY inefficient" in the source). At this geogrid width, lines_per_block = 1000 implies a ~13-15 GB device transient for cf32 — OOM territory on 16 GiB consumer cards. High-rate NVML sampling on an A100 (scale-up section above) recorded a 16,951 MiB total allocation peak (baseline 1,565 MiB) at lines_per_block = 1000 on a 23.5k-wide geogrid — scaling consistent with the model, so the hazard is evidence-backed rather than estimated; the transient's dwell is ~tens-to-hundreds of ms, so coarse samplers understate it. Memory-budget-based or adaptive block sizing — or a chip-free interpolation path — would remove a real footgun.
What this would mean for users (bounded expectations: 4-5× hypothesis, workflow-level gzip floor)
Bounded, honest framing:
- Phase-level: the existing shared path measured 5.1× (consumer GPU). This is feasibility evidence for the shared infrastructure, not a forecast for a completed SLC backend. As an Amdahl-style hypothesis: the SLC-specific steps account for ~7.6 % of CPU kernel wall here (production 16.27 s vs parity 15.04 s), so an effective 4-5× is plausible if they port with comparable efficiency — to be verified, not promised.
- Workflow-level: for COMPASS-style CSLC runs the geocoding phase also contains single-threaded gzip HDF5 output (~14 s of a ~30 s phase on my host, with the current serial workflow and gzip-4 settings). That is an output-configuration concern outside isce3 and is reported separately; with those settings a CUDA kernel alone cannot shrink the phase below that ~14 s floor (overlapping compression with GPU compute could change this in the future). Stated so this proposal doesn't overclaim.
Maintenance posture and design questions
- Builds on the maintained CUDA class rather than adding a parallel implementation; CPU/CUDA share semantics via the acceptance tests above.
- Tests follow the existing ctest GPU-label separation so CI without a GPU is unaffected.
- Design questions I'd like maintainer input on: (a) whether SLC semantics belong in
Geocode itself or a derived/composed API;
(b) whether the Poly2d carrier path is required for the first increment; (c) for each semantic difference listed above, align-to-CPU or document-and-keep — e.g. should the extension default delta_range to the CPU path's 1e-8 (keeping the parameter), and should correction-LUT out-of-bounds skip the correction (CPU behavior) or mask the pixel (current CUDA behavior)?
Summary
isce3::geocode::geocodeSlc(the kernel behind Sentinel-1 CSLC generation in COMPASS, and SLC-domain geocoding generally) has no CUDA backend. Profiling on a real Sentinel-1 IW burst (~16 s wall per burst on a 16-core host) shows its time is concentrated in the per-pixelgeo2rdrNewton solve — functionality thatisce3::cuda::geocode::Geocodealready implements on-device andthat the production workflow code (
nisar/workflows/geocode_insar.py) already drives.This issue proposes building a CUDA
geocodeSlcbackend on the existingcuda::geocode::Geocodeinfrastructure rather than writing a parallel CUDA implementation from scratch. Whether that takes the form of an extension ofGeocode, composition, or a shared lower-level backend is a design question I'd like maintainer input on (below).An end-to-end trial of the existing CUDA class on the same real burst supports feasibility: it reproduces CPU geometry and amplitude to within quantified tolerances and runs the shared portion ~5× faster than the CPU path on a consumer GPU. I am prepared to implement this and want to align on scope and design constraints first.
Key results
geo2rdrshare of thegeocodeSlccall (py-spy ×2 + perf)isce3::core::Orbit::interpolateshare of whole-run user cyclesAll numbers are hardware- and workload-specific; measurement protocols, fairness notes, and caveats are in the collapsed sections
below. Full provenance: RTX 5080 evidence bundle ·
A100 evidence bundle.
Motivation: where the CPU time actually goes (profiling methodology and attribution)
Workload: one Sentinel-1 IW3 burst (Boso, Japan), geogrid 3225 × 20939 (~67.5 Mpx), biquintic DEM interpolation, SINC SLC interpolation.
Host: Intel Core Ultra 9 285H (16 cores), 96 GB RAM, NVIDIA RTX 5080 16 GiB (measurement provenance and scripts:
RTX evidence bundle).
The
geocodeSlccall takes ~16 s wall per burst (array mode, OpenMP, warm caches).Three measurements agree on the attribution (the parallel py-spy run had worker-0 sampling bias and lag warnings and serves as a
consistency check; the OMP=1 and perf runs are the primary evidence):
--native, parallel run (5079 samples)--native,OMP_NUM_THREADS=1(13884 samples)perf record(cycles:u, all threads, 36562 samples)Sharper still:
isce3::core::Orbit::interpolatealone is ~54 % of all user cycles of the whole run — the Newton solve's cost isdominated by per-iteration Hermite orbit interpolation (
detail::geo2rdr,Geo2Rdr.icc). The remainder of the kernel isSINC interpolation (~20 % of single-thread kernel CPU) and DEM interpolation (~10 %). Note the different denominators: the 69-77 % figures are shares of the
geocodeSlccall; the 54 % figure is a share of whole-run user cycles.perf stat: IPC ≈ 4.0, branch-miss < 0.7 % — consistent with compute-bound behavior (no cache/bandwidth counters were collected, so stated as "consistent with", not proven).This is why a CUDA path is worth having: the hot loop is exactly the part
cuda::geocode::Geocodealready runs on-device (geoToRdrIndiceskernel calls the devicegeo2rdr, all-double).Feasibility evidence: existing CUDA Geocode on the same burst (trial protocol, timings, fp64, agreement)
To bound what reuse buys without writing any new CUDA code, I ran the existing
isce3.cuda.geocode.Geocode(SINC mode) against the CPUisce3.geocode.geocode_slcon the same burst, same geogrid, same DEM, same geo2rdr parameters (delta_rangematched to the CPU's hardcoded 1e-8).Because the CUDA class lacks the SLC-specific phase steps (gap list below), the primary CPU comparator is a "parity" variant with
carrier/Doppler/flattening neutralized, so both sides do the same work; the production-semantics CPU run is also shown.
RTX 5080 (16 GiB, consumer, 1:64 fp64), n = 5, median (IQR):
≈ 5.1× on the semantic-parity public-API comparison. Notes on fairness: the timed GPU region includes raster read, H2D, kernels,
D2H, and the raster API writes (final close/flush falls outside the timed region), while the CPU region is pure compute on preloaded
arrays — the asymmetry biases against the GPU. This is a production-shaped comparison, not a kernel microbenchmark.
Notably the device geo2rdr is all-double, yet the RTX 5080's weak nominal fp64 ratio (1:64) did not prevent the observed 5.1×
public-API speedup (GPU kernel counters were not collected, so no stronger claim is made). The SLC phase steps to be ported (carrier deramp/reramp, Doppler demodulation, flattening) also need double precision: a standalone kernel study measured ~10 rad unwrapped phase error in fp32 flattening at this slant-range scale, so fp64 is mandatory there. In that study (synthetic 1046×645 grid, flattening kernel only) the fp64 variant ran at 5.5× the fp32 one — far from the 64× nominal throughput gap — but the study covers neither the full phase chain nor burst-scale arrays, so per-step cost on real bursts remains to be measured during the port.
Agreement, measured through the actual code paths (a coordinate-encoded cf32 raster, so indices are quantized by float32):
subswath validity region), zero mismatches.
remains to be characterized).
lines_per_block.lines_per_block200→100 alters 4 px of 42.8 M (0.093 ppm, max |Δ| 0.0204); the coordinate rasters differ at the same 4 px, locating the origin in the per-block geometry stage — consistent with per-block DEM interpolation or margin handling — rather than in the SLC interpolator. Left as an open question for the shared infrastructure.Scale-up: datacenter GPU, full subswath and full frame (A100 9-burst / 27-burst results, VRAM scan)
To test whether the single-burst consumer-GPU result holds at production scale and on datacenter hardware, I repeated the trial on
an NVIDIA A100-SXM4-80GB (cloud instance; CPU = 2× AMD EPYC 7742, 128 cores, host-shared — CPU figures below carry that caveat; the RTX numbers above remain the controlled comparison).
Same isce3 source SHA, same trial semantics,
lines_per_block = 200for comparability. Full artifacts:A100 evidence bundle.
All 9 IW2 VV bursts of the same Sentinel-1 acquisition (geogrids ~3480 × 23300 each, 734 Mpx total), n = 3 per burst:
The geometry/amplitude agreement replicates on every burst of the subswath: valid masks identical on all 9 (Jaccard 1.0), range index max |Δ| ≤ 0.0195 px, azimuth ≤ 0.0013 px, amplitude r ≈ 1.0 with worst per-burst max relative amplitude error 4.3e-2 (per-burst maxima 0.4-4.3e-2; same tail character as the single-burst run).
A full frame (27 VV bursts across IW1/IW2/IW3, 2.04 Gpx) geocodes in a 62.0 s sequential batch wall (~33 Mpx/s sustained), demonstrating the workload at frame scale.
The 80 GB card also allows a
lines_per_blockscan that doubles as memory evidence for the chip-buffer issue (collapsed section further below).Wall time and peak device memory (out-of-process busy-poll NVML sampling, ~58k queries/s; coarser 20-50 ms samplers can miss or severely understate this transient — its >4 GiB dwell is only ~40-215 ms, and sampler cadence and process scheduling both contribute):
Two conclusions: (a) baseline-subtracted peak scaling is consistent with the chip-buffer model (
n_elem_out × 81 × 8 B, SINC_ONE² = 81; baseline 1,565 MiB) — atlines_per_block = 1000(the runconfig-level default in adjacent workflows) the measured allocation footprint of 16,951 MiB exceeds a 16 GiB consumer card's total VRAM, so the OOM hazard on such cards is evidence-backed rather than estimated (no OOM was provoked on the RTX itself); (b) larger blocks do pay on large-memory cards (2.37 s → 1.49 s), with most of the gain already at 1000 — supporting memory-budget-based/adaptive block sizing rather than any fixed default. Outputs were bit-exact across all scanned block sizes on this burst; the 4-px block-size sensitivity observed on the RTX burst did not reproduce here.Proposal: build on the
cuda::geocode::Geocodeinfrastructure, don't fork itThe shared machinery is already there and driven by the production workflow code (
geocode_insar.py): per-pixeldevice geo2rdr with azimuth/range correction LUTs, projection + DEM interpolation, masked block machinery with the same interpolation margin as the CPU path, and a SINC interpolator with the same SINC_LEN/SINC_SUB and
thrust::complex<float>support.What
geocodeSlcneeds that the CUDA class lacks today (from a line-by-line diff ofgeocodeSlc.cppvsGeocode.cu); this is the implementation scope:native-Doppler data).
uncorrectedSRangeoption).slicedRadarGridmasking.geocodeSlcaccepts both LUT2d and Poly2d carriers; the existing device LUT2d plumbing may be reusable for the LUT2d case.Semantic differences that should become explicit porting requirements with acceptance tests (they currently differ silently):
geo2rdrtolerance: CPU hardcodesdelta_range = 1e-8; CUDA takes it as a parameter.Pre-existing sizing issue the port should address (SINC chip-buffer transient; OOM hazard on 16 GiB cards)
The SINC path preallocates
n_elem_out × SINC_ONE²chips per block (self-described "VERY inefficient" in the source). At this geogrid width,lines_per_block = 1000implies a ~13-15 GB device transient for cf32 — OOM territory on 16 GiB consumer cards. High-rate NVML sampling on an A100 (scale-up section above) recorded a 16,951 MiB total allocation peak (baseline 1,565 MiB) atlines_per_block = 1000on a 23.5k-wide geogrid — scaling consistent with the model, so the hazard is evidence-backed rather than estimated; the transient's dwell is ~tens-to-hundreds of ms, so coarse samplers understate it. Memory-budget-based or adaptive block sizing — or a chip-free interpolation path — would remove a real footgun.What this would mean for users (bounded expectations: 4-5× hypothesis, workflow-level gzip floor)
Bounded, honest framing:
Maintenance posture and design questions
Geocodeitself or a derived/composed API;(b) whether the Poly2d carrier path is required for the first increment; (c) for each semantic difference listed above, align-to-CPU or document-and-keep — e.g. should the extension default
delta_rangeto the CPU path's 1e-8 (keeping the parameter), and should correction-LUT out-of-bounds skip the correction (CPU behavior) or mask the pixel (current CUDA behavior)?