Stop the numba fast path claiming CRSs it cannot reproduce (#3697) - #3698
Conversation
The fast-path parameter extractors accepted CRS definitions whose PROJ parameters they do not model, then projected them with the wrong constants. Sweeping the EPSG database, 220 of the 2685 projected CRSs the fast path accepted came back more than 5 m from pyproj. Four causes: - _lcc_params and _aea_params defaulted lat_2 to a lat_1 that was already in radians, so math.radians ran over it twice. A 47 degree standard parallel became 0.82 degrees, the pair then looked distinct, and the two-parallel branch computed a cone constant for a parallel pair that does not exist. PROJ writes a single-parallel LCC with no lat_2 at all, so every 1SP definition hit it: 185 EPSG codes, worst 3819 m. - _aea_params, _cea_params, _laea_params, _stere_params, _sinu_params and _sterea_params never read units, so a CRS in feet was read as metres. They have no to_meter factor to hand back the way _lcc_params and _tmerc_params do, so they now decline a non-metre CRS instead. - Every extractor ignored +axis=. The kernels emit easting/northing, so a west/south grid came out negated and swapped (EPSG:2046-2055, 7085 km). - _is_wgs84_compatible_ellipsoid read a missing +datum= key as WGS84. PROJ only emits that key for datums it has an alias for, so GGRS87, Israel 1993 and VN-2000 took the fast path on their GRS80/WGS84 ellipsoid and their shift was dropped. The guard now measures the offset instead of matching names, with the threshold above the NAD83-as-WGS84 approximation this module already makes on purpose. After the fix the same EPSG-wide sweep reports 0 CRSs more than 5 m out, and the fast path still covers 2649 of them. Both dispatchers share these extractors, so this covers numpy, dask+numpy and the streaming path via try_numba_transform and cupy, dask+cupy via try_cuda_transform, plus output-bounds estimation in transform_points.
brendancol
left a comment
There was a problem hiding this comment.
PR Review: Stop the numba fast path claiming CRSs it cannot reproduce (#3697)
Reviewed the full _projections.py around the diff, not just the changed
hunks, and re-ran the EPSG-wide differential on the branch to check the
claimed numbers.
Blockers (must fix before merge)
None.
Suggestions (should fix, not blocking)
-
_projections.py_is_wgs84_compatible_ellipsoidnow rejects on three
unrelated grounds: ellipsoid axes,+axis=orientation, and datum offset.
The name only describes the first. Every caller wants "can the kernels
reproduce this CRS", so either rename it to something like
_fast_path_supports_crsor say plainly in the docstring that it is the
general fast-path precondition. A future reader adding a projection will
otherwise wonder why an ellipsoid predicate is checking axis order. -
The axis check reads
d.get('axis'), which only exists when PROJ emits
+axis=in the proj4 export. Several CRSs declare north/east ordering in
WKT and still come back withaxis=None(EPSG:2193, EPSG:3346). Those are
fine, because every transformer in this package usesalways_xy=True,
which normalizes ordering but not direction, and direction differences do
surface as+axis=. That reasoning is load bearing and is not written
down anywhere. Add it to the comment so nobody later "fixes" the check by
widening it toaxis_infoand breaks the two CRSs above.
Nits (optional improvements)
-
_datum_offset_from_wgs84probes at the CRS'slon_0/lat_0. For a few
CRSs that origin sits outside the area of use. Helmert shifts vary slowly,
so the measured offset is representative, and the EPSG-wide check backs
that up. Worth one sentence in the docstring saying the probe point is
deliberately approximate. -
Bare
except Exceptionin_datum_offset_from_wgs84. Consistent with the
surrounding_crs_to_dictcallers, so not worth changing on its own, but
it will swallow a genuine pyproj bug as "no shift detected", which fails
open toward the fast path. Failing closed would be safer given this
function is a correctness guard. -
The measured cost is 9.4 ms on the first call per CRS and about 9.6 us on
cache hits, and the dispatcher can call it up to seven times per chunk
while it tries each projection family. That is noise next to a 512x512
kernel, but the PR body would be more useful with the number in it, since
the change adds pyproj work to a path whose whole purpose is avoiding
pyproj. -
36 CRSs stop taking the fast path and now go through pyproj, which is
slower for those users. That is the correct trade, they were getting wrong
answers, but it is a performance characteristic worth stating in the PR
body rather than only the correctness win.
What looks good
- The
lat_2fix is the right shape: convert the degree values once rather
than paper over it with anabs(lat_1 - lat_2)tolerance bump. Keeping
lat_1_degandlat_2_degas named locals makes the double conversion
hard to reintroduce silently. - Measuring the datum offset instead of extending the datum-name whitelist
is the fix that keeps working as PROJ adds datums. The previous two
attempts at this guard (#2651, #3275) both grew name lists and both leaked
again; this one does not have that failure mode. - Declining non-metre CRSs in the six extractors that have no
to_meterto
return, while leaving_lcc_paramsand_tmerc_paramsfoot support
intact, is the minimal correct split. The parametrized test asserting
metres-accepted and feet-declined for all six pins it. - The threshold is chosen against a real number rather than a round guess,
and the comment records why 5 m sits where it does relative to the
deliberate NAD83-as-WGS84 approximation. - The test file covers all four causes, both dispatchers, and dask-vs-numpy
parity, and the assertions are lower bounds on measured offsets rather
than exact PROJ-version-specific values.
Verification I ran
- EPSG-wide differential on this branch: 5304 projected CRSs checked, 2649
take the fast path, 0 land more than 5 m from pyproj. Matches the PR body. - Kernel-vs-pyproj forward and inverse sweep across every supported family:
unchanged from before the PR, the only residuals being the documented
1.6 mm authalic series truncation and the deliberate 0.3 to 1.8 m NAD83
approximation. - Resampling invariants on numpy, cupy, dask+numpy and dask+cupy: constant
raster stays constant, same-CRS nearest is a bitwise identity, no NaN leak
into fully valid stencils, backends agree to 1.2e-13. Unaffected by this
PR, which confirms the change is confined to CRS acceptance.
Checklist
- Algorithm matches reference (PROJ
lccandaeasetup,n = sin(lat_1)for 1SP) - All implemented backends produce consistent results
- NaN handling is correct (untouched by this PR, re-verified)
- Edge cases are covered by tests
- Dask chunk boundaries handled correctly (parity test included)
- No premature materialization or unnecessary copies
- Benchmark exists or is not needed. No benchmark covers CRS dispatch;
the added pyproj work is bounded by the cache but unmeasured in asv - README feature matrix updated (not applicable, no new public function)
- Docstrings present and accurate
- Fail closed when the datum offset cannot be measured. Returning None on an exception meant an unverifiable datum kept the fast path, which is the wrong direction for a correctness guard; it now returns inf so the caller sends the CRS to pyproj. None is still returned when there is genuinely nothing to measure (no pyproj, or a lite CRS with no geodetic base, where the curated built-in table already decides). - Write down why the axis check reads the PROJ dict rather than axis_info. Only axis direction matters, because always_xy=True already normalizes ordering; EPSG:2193 and EPSG:3346 are north/east in WKT and reproject correctly today. Widening the check would break them, so the comment says not to, and a test pins it. - Say in _is_wgs84_compatible_ellipsoid's docstring that it is the general fast-path precondition, not only an ellipsoid test. Kept the name: nine call sites and the #3275 tests reference it, and renaming buys nothing the docstring does not. - Note in _datum_offset_from_wgs84 that the lon_0/lat_0 probe point is deliberately approximate, since a Helmert shift varies slowly enough that an off-centre reading still lands far from the threshold.
brendancol
left a comment
There was a problem hiding this comment.
PR Review (follow-up pass): fast-path guard #3697
Re-reviewed after b7d5789 and 6190122. Disposition of every finding from
the first pass, then a fresh look at the new code.
Disposition of the previous findings
| Finding | Verdict |
|---|---|
_is_wgs84_compatible_ellipsoid name no longer describes what it does |
Fixed, partially. Docstring now states plainly that it is the general fast-path precondition and asks future checks to live there. Kept the name: nine call sites plus the #3275 tests reference it, and a rename buys nothing the docstring does not. |
Axis check reads the PROJ dict, not axis_info; reasoning undocumented |
Fixed. The comment now explains that only direction matters because always_xy=True normalizes ordering, names EPSG:2193 and EPSG:3346 as the CRSs a widened check would break, and says not to widen it. test_axis_order_only_crs_keeps_fast_path pins it. |
| Probe point may sit outside the area of use | Fixed. Docstring says the probe point is deliberately approximate and why a slowly-varying Helmert shift makes that safe. |
Bare except Exception fails open toward the fast path |
Fixed. Now returns inf on a failed measurement so the caller rejects, while still returning None when there is genuinely nothing to measure (no pyproj, or a lite CRS with no geodetic base). test_measurement_failure_fails_closed pins it. |
| Timing numbers absent from the PR body | Fixed. PR body now carries the 9.4 ms cold / 9.6 us warm figures and the per-chunk estimate. |
| 36 CRSs losing the fast path not stated as a perf characteristic | Fixed. PR body now says so explicitly. |
| No asv benchmark covers CRS dispatch | Deferred, not fixed. Adding one means a new benchmark class and a CRS fixture set, which is a bigger diff than the fix it would measure. The cost is bounded by the cache and measured by hand above. Worth a follow-up if the dispatch path gets touched again. |
New review of the follow-up commits
No blockers.
One thing I checked specifically, because the fail-closed change is the
riskiest of the four: returning inf on a measurement failure could in
principle strip the fast path from CRSs that were fine, if some common CRS
happens to throw inside the probe. Re-ran the EPSG-wide differential on
6190122 to rule that out:
checked=5304 fast-path-taken=2649 >5m-off=0
Identical to the pre-follow-up run, so nothing new is being rejected. The
change is a pure safety improvement rather than a behaviour change on real
CRSs.
Two smaller observations, neither worth blocking:
-
The
Noneversusinfsplit now carries real meaning, and the two cases
are only a few lines apart. The docstring distinguishes them clearly, but
it is the kind of contract that decays. The new test locks theinfhalf;
test_lite_crs_has_no_geodetic_baselocks theNonehalf. Between them
the contract is pinned, so this is an observation, not a request. -
test_measurement_failure_fails_closedmonkeypatches
pyproj.Transformer.from_crsglobally and clears the cache in afinally.
That is correct as written, andmonkeypatchundoes the patch itself, but
it does mean the test is order-sensitive if the cache were ever populated
by an earlier test with the patch still live. Thecache_clear()on both
sides handles it.
What still looks good
Everything from the first pass holds. The follow-up did not weaken any of
it, and the fail-closed change makes the guard behave the way a correctness
guard should: an unverifiable datum now goes to pyproj rather than to
kernels that would silently skip its shift.
Checklist
- All first-pass findings dispositioned, six fixed and one deferred with a reason
- Follow-up commits re-verified against the EPSG-wide differential
- New behaviour covered by tests
- Full reproject suite still green (622 passed, plus 36 in the new file)
- flake8 and isort clean
EPSG:10481, 9549 and 9207 are recent additions to the EPSG registry and PROJ ships a snapshot of it, so an older PROJ in CI would raise inside CRS construction instead of reaching the assertion. Route construction through a helper that skips in that case.
Closes #3697
The fast-path parameter extractors in
_projections.pyaccepted CRSdefinitions whose PROJ parameters they do not model, then projected them with
the wrong constants. Sweeping the EPSG database, 220 of the 2685 projected CRSs
the fast path accepted came back more than 5 m from pyproj, with no warning.
What changed
_lcc_params/_aea_params: take the standard parallels in degrees beforeconverting. The
lat_2fallback waslat_1, already in radians, somath.radiansran over it twice; a 47 degree parallel became 0.82 degrees andthe two-parallel branch built a cone constant for a parallel pair that does
not exist. PROJ writes a single-parallel LCC with no
lat_2at all, so every1SP definition hit it (185 EPSG codes, worst 3819 m).
_aea_params,_cea_params,_laea_params,_stere_params,_sinu_params,_sterea_params: decline a non-metre CRS. These build metre-denominatedconstants and have no
to_meterfactor to return, unlike_lcc_paramsand_tmerc_params, which keep their foot support._is_wgs84_compatible_ellipsoid: reject a non-enu+axis=, and measure thedatum offset from WGS84 rather than matching datum names. PROJ only emits
+datum=for datums it has an alias for, so GGRS87, Israel 1993 and VN-2000arrived with the key missing and were read as WGS84. The 5 m threshold sits
above the NAD83-as-WGS84 approximation this module already makes on purpose
and far below every datum the guard has to catch.
Result
Same EPSG-wide sweep, before and after:
The 36 CRSs that stopped taking the fast path are the ones it was getting
wrong. They now go through pyproj, which is slower for those users; that is
the intended trade.
Cost
The datum measurement is one pyproj
Transformerbuild per CRS, cached withlru_cache. Measured on the dev box: 9.4 ms on the first call for a given CRS,about 9.6 us on cache hits. The dispatcher can hit the cache up to seven times
per chunk as it tries each projection family, so roughly 70 us per chunk on top
of a 512x512 kernel.
Backends
try_numba_transform(numpy, dask+numpy, streaming) andtry_cuda_transform(cupy, dask+cupy) share these extractors, and
transform_pointsuses them foroutput-bounds estimation, so all four backends and the bounds math are covered
by the same change. Tests assert both dispatchers decline, and that dask+numpy
matches numpy on a repaired CRS.
Test plan
xrspatial/tests/test_reproject_fast_path_guard_3697.py, 36 passed /1 skipped, covering all four causes plus the CPU and CUDA dispatchers
test_reproject.py,test_lite_crs.pyand the reproject: LAEA inverse off by up to 2.6 km, AEA/CEA inverse off by ~5 m vs PROJ #3274 / reproject: spherical-ellipsoid CRSes (MODIS sinusoidal) take the WGS84 fast path, ~19-24 km error #3275 files, plus 55 more in theStreaming reproject thread pool aborts the process when numba parallel kernels run concurrently #3141 / itrf_transform: Helmert scale read as ppb but PROJ files publish ppm, ~2 cm error #3276 / cupy / streaming files
path (merc, emerc, UTM, tmerc incl. the
lat_0 != 0Zb path, 2SP LCC,AEA, CEA, sinusoidal, LAEA oblique/equatorial/polar, polar stereographic,
oblique stereographic)
constant raster stays constant, same-CRS nearest is bitwise identity, no
NaN leak into fully valid stencils, backends agree to 1.2e-13