perf: fast feature-frame builds and response-body release in the chunked OGC path - #388
Open
thodson-usgs wants to merge 1 commit into
Open
thodson-usgs wants to merge 1 commit into
thodson-usgs wants to merge 1 commit into
Conversation
thodson-usgs
force-pushed
the
perf/ogc-shaping-fastpaths
branch
from
August 26, 2026 21:18
1602d15 to
460905a
Compare
thodson-usgs
marked this pull request as ready for review
September 6, 2026 01:10
thodson-usgs
force-pushed
the
perf/ogc-shaping-fastpaths
branch
2 times, most recently
from
September 15, 2026 20:49
0aa8178 to
b7e5074
Compare
The per-chunk parse runs on the fan-out's event loop, so shaping CPU is on every chunked call's critical path. Separately, each aggregate kept for resume shared its first page's decompressed body, so a one-page-per-chunk fan-out held the whole download in memory until the call finished. Feature frames now take two fast paths. Flat properties build through `pd.DataFrame` rather than `pd.json_normalize`, with nested values found by scanning only object-dtype columns. An all-2D-point page builds its geometry in one `geopandas.points_from_xy` call over two flat coordinate lists rather than per-feature `GeoDataFrame.from_features`, and keeps nested properties as raw dicts the way `from_features` does. Non-point or malformed geometry, null coordinates, and a `geometry` property column fall back to `from_features`; the plain path still normalizes nested properties. Transport frees a page body once it is parsed and reported, but only when it owns the client -- `client is None`, which covers the fan-out's ambient client. A caller-supplied client keeps its page bodies. An aggregate is always a copy with an empty body, so status, headers, URL and elapsed stay readable. ADR 0006 records both contracts. `utils.format_datetime` shaped qw-service responses, lost its last caller in 491eb5c, and appears in no docs or demos. It now warns through `_deprecation.warn_deprecated` with a 2027-09-15 horizon. Behavior change: the raw aggregate reached through `partial_response` -- on `ChunkInterrupted`, `FanOutInterrupted`, and the `ChunkedCall` they carry -- no longer carries body bytes. What it carried was one arbitrary page's fragment, not the query's data; use the returned frame. `BaseMetadata` is unaffected: it keeps the url, elapsed and headers, and has never retained the response object. Measured on an M3 Pro, Python 3.12, pandas 3.0.5, geopandas 1.1.3, against a `main` worktree. Frame builds, median of seven runs, traced peak separately: - 100k flat point features: 548 -> 101 ms, peak 64.1 -> 20.8 MiB - 50k nested point features: 294 -> 50 ms, peak 33.7 -> 11.2 MiB - 100k features without geometry: 110 -> 84 ms, peak 58.8 -> 15.5 MiB - 50k polygon features: unchanged, as the fallback still handles them - 100k flat plain frames: 211 -> 75 ms Replaying a recorded 93k-row 12-chunk call at zero latency: 0.84 -> 0.42 s, traced peak 165.8 -> 114.0 MB, and 165.8 -> 149.9 MB when pages arrive concurrently. The unfanned peak is unchanged at 211 MB: it is one 40 MB page's decode spike, which neither change touches. Every fast path is verified to produce frames identical to the `from_features` output. 1200 offline tests at 99.02% branch coverage; mypy, ruff, xenon, complexipy and import-linter pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ppUeFxCydyd4n1k6TFXuq
thodson-usgs
force-pushed
the
perf/ogc-shaping-fastpaths
branch
from
September 15, 2026 20:51
b7e5074 to
23b8bc1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A design review of the async parallel chunking stack (planning → fan-out → pagination → shaping/combining → transport), with each candidate improvement measured against real Water Data queries. Two changes survived; everything else is listed below with the numbers that ruled it out. A deprecation of the one dead function a package-wide sweep found rides along.
1.
perf(shaping): vectorized feature-frame fast paths.Flat feature properties — every Water Data and NGWMN collection — build through
pd.DataFramerather thanpd.json_normalize; nested values are found by scanning only object-dtype columns after the cheap build. An all-2D-point page builds its geometry in one vectorizedgeopandas.points_from_xycall over two flat coordinate lists, rather than per-featureGeoDataFrame.from_features. The spatial fast path keeps nested properties as raw dicts, which is whatfrom_featuresdoes; the plain path still normalizes them with underscores.Non-point or malformed geometry, null coordinates, and a
geometryproperty column each fall back to the previous path. This CPU runs on the fan-out's event loop, so it is on every chunked call's critical path.2.
perf(transport): free page bodies once parsed, where transport owns them.Every per-chunk aggregate stored for resume shared its first page's decompressed body, so a one-page-per-chunk fan-out held the whole download in memory until the call finished.
paginatelikewise pinned its first page's body for the whole walk.A page body is now freed after it is parsed and reported, but only when transport owns the client —
client is None, which covers the fan-out's ambient client. A caller who supplies their ownclient=keeps every page body, because those bytes are theirs to read. An aggregate is a copy with an empty body regardless of ownership; status, headers, URL and elapsed are unchanged.Behavior change (in NEWS): the raw aggregate reached through
partial_response— onChunkInterrupted,FanOutInterrupted, and theChunkedCallthey carry — no longer carries body bytes. What it carried was one arbitrary page's fragment, not the query's data; use the returned frame.The scope is narrower than that may read:
BaseMetadata.__init__keepsurl,query_time,headerandcommentand discards the response object; no subclass stores it (self.responseappears nowhere in the package). A caller reading metadata sees no change.partial_responseis the only reachable surface — the interruption and resume path, plus thewaterdata/nearest.pywrapper that forwards it.maindocumented the body. Bothpartial_responsedocstrings say "raw aggregate", where raw contrasts with finalized (not yet passed through finalize into a(df, metadata)pair), not with empty; ADR 0006 had no body clause at all. This PR adds that clause, recording both the aggregate contract and the page-body lifetime rule.3.
chore(utils): deprecateformat_datetime.A dead-function sweep over the package found exactly one orphan: it shaped qw-service responses, lost its last caller in
491eb5c3, and appears in no docs or demos. It is a public name, so it warns through_deprecation.warn_deprecatedwith a 2027-09-15 horizon rather than disappearing.Benchmarks
Re-measured at the current branch head against a
mainworktree, on an M3 Pro, Python 3.12, pandas 3.0.5, geopandas 1.1.3. Frame builds are the median of seven runs over ten property columns, with traced peak taken in separate runs.mainmainget_daily, median of 7 trials/armThe unfanned peak is one 40 MB page's JSON decode spike, which neither change touches — it is bounded by a single page. The live row was measured on an earlier revision of this branch and has not been re-run; the replay CPU-path A/B measures the same −50% now as it did then, so it should still hold.
Read the two latency rows as the honest end-to-end figure: once real network latency is in play, a cold query gains a few percent. The CPU and peak-heap rows are what this PR is for — they are what a cached query, a local re-parse, or a memory-bound fan-out actually sees.
Correctness: outputs verified identical (
assert_frame_equal) againstfrom_featuresandjson_normalizefor full, missing, mixed, null and malformed geometry, polygons, nested and sparse properties, colliding property names, 3-D coordinates, single-feature pages, and a properties-levelidcolumn. 1200 offline tests at 99.02% branch coverage, plus a focused pandas-2 run;mypy --strict, ruff, xenon, complexipy and import-linter pass.Evaluated and rejected (with numbers)
DataFrame(records)): 1.08x best case — pandas 3's record path is already good.cursor=<opaque>, andnumberMatchedis absent fordaily), so page N+1's URL cannot be built ahead of page N.MRE
Offline, deterministic, no API key or quota. Compares the fast path against the fallback it replaces, on flat and on nested properties:
Typical output on an M-series laptop (single run, six property columns, so faster than the table's ten-column median):
Methodology notes
git worktreeofmain, one arm per process, rather than monkeypatching the old functions back in.httpx.MockTransport, sleeping each request's recorded wall time — exact pairing, no quota, no cache noise.tracemallocpeak, one arm per process, with response bodies freshly allocated per request.🤖 Generated with Claude Code
https://claude.ai/code/session_014ppUeFxCydyd4n1k6TFXuq