perf: fast feature-frame builds and response-body release in the chunked OGC path - #388
Draft
thodson-usgs wants to merge 8 commits into
Draft
perf: fast feature-frame builds and response-body release in the chunked OGC path#388thodson-usgs wants to merge 8 commits into
thodson-usgs wants to merge 8 commits into
Conversation
Two fast paths in the OGC feature-frame builders, both falling back to the previous implementation whenever their precondition fails: - _properties_frame: flat properties (every Water Data / NGWMN collection) build with pd.DataFrame instead of pd.json_normalize (~2x). One nested value anywhere routes the page through json_normalize as before. - _spatial_feature_frame: all-2D-point pages build geometry with one vectorized shapely.points call instead of the per-feature Python walk in GeoDataFrame.from_features (~3.4x). Any non-point or malformed geometry falls back. This CPU runs on the fan-out's event loop, so it is on the critical path of every chunked call. Measured on a 93k-row, 12-chunk get_daily: CPU path -45%, cache-hot wall 1.33s -> 0.95s median (7 trials/arm); output verified identical against recorded API responses, including missing-geometry, polygon, nested-properties, and properties-id edge cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
An aggregated response's content was one arbitrary page's bytes (its base's), and keeping it had a real cost: every per-chunk aggregate in FanOut._chunks shared its first page's body, so a ~1-page-per-chunk fan-out held the entire decompressed download until the call finished (32 full pages ~ 1.3 GB). paginate likewise held the first page's body for the whole walk. Clear the body on the merged copy and on the initial response once parsed; status, headers, URL, and elapsed are unchanged, and live per-page responses are untouched. Measured: -19% peak Python-heap on a 12-chunk, 93k-row replay (164 MB -> 133 MB), scaling with chunk count x page size. Behavior change (documented in NEWS): the response behind a completed call's metadata and FanOutInterrupted.partial_response now has an empty body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
Post-review cleanup of the two perf commits, output-identical (verified against recorded API responses and the full suite): - _spatial_feature_frame: extract the fast build as _point_feature_frame and share one id-overwrite + reorder tail between fast path and from_features fallback (CC 10 -> 5). - _point_geometries: one pass instead of four, bailing on the first non-point feature instead of scanning the whole page first. - _properties_frame: take features (both callers spelled the same extraction) and detect nested values by scanning only object-dtype columns after the cheap build — the full pre-scan cost a third of the win it guarded. MRE: plain 1.8x -> 2.5x, spatial 3.4x -> 4.6x. - combining: name the httpx body-release idiom once (_drop_body, beside _set_response_url) and state the emptied-body contract in _merge_response's docstring; pagination calls the helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
A dead-function sweep found exactly one: format_datetime shaped qw service responses and lost its last caller when qwdata usage was removed (491eb5c); it appears in no docs or demos. Public name, so it warns through the shared mechanism with a 2027-08-22 horizon rather than vanishing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
Second cleanup pass over the branch (reuse / simplification / efficiency / altitude), output-identical against the recorded API fixtures: - _point_geometries builds through gpd.points_from_xy over two flat x/y lists, the idiom nwis already uses, retiring the package's only direct shapely import (shapely is geopandas' own dependency, undeclared here). The pair form it replaces was ~1.6x slower; inlining the per-feature helper also retires the _NON_POINT sentinel and its three-way contract. - _properties_frame gates its Python dict scan behind infer_dtype, so only a genuinely mixed object column is walked. Under pandas 2 -- supported, and where every string column is object dtype -- that is 2.3x on the helper; spatial page build is now 4.9x the pre-branch path. - format_datetime documents its deprecation in prose: a bare `.. deprecated::` has a required version argument, so Sphinx was eating the first word of the body as the version on the published API page. - combining's docstring names its second responsibility (adjusting fetched responses) and why it sits below transport. - The deprecation test moves to utils_test, where per-surface deprecation tests live; deprecation_test keeps the mechanism claims. - Malformed-coordinate cases are parametrized, and a ragged pair now covers the array-build refusal that no test reached. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh
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 evaluated experimentally against real Water Data queries. Two changes survived the bar; everything else is reported below with the numbers that killed it. A post-review cleanup pass (4 parallel review agents: reuse / simplification / efficiency / altitude) converged the fast-path code and made it faster still, and a follow-up pass trimmed comments to current-code constraints and deprecated the one dead function a package-wide sweep found.
Accepted (this PR):
perf(shaping): vectorized feature-frame fast paths. Flat feature properties (every Water Data / NGWMN collection) build viapd.DataFrameinstead ofpd.json_normalize(~2.5x — nested values are detected by scanning only object-dtype columns after the cheap build); all-2D-point pages build geometry via one vectorizedgeopandas.points_from_xycall over two flat coordinate lists instead of per-featureGeoDataFrame.from_features(~4.8x). Nested properties, non-point/malformed geometry, or ageometryproperty column all fall back to the previous path. This CPU runs on the fan-out's event loop, so it sits on every chunked call's critical path.perf(transport): free response bodies once parsed. Every per-chunk aggregate stored for resume shared its first page's decompressed body, so a ~1-page-per-chunk fan-out held the whole download in RAM until the call finished.paginatelikewise pinned its first page's body for the whole walk. Aggregates now carry an empty body via a single named helper (combining._drop_body); status/headers/URL/elapsed unchanged. Behavior change (in NEWS): the aggregatehttpx.Responsebehind a call's metadata andFanOutInterrupted.partial_responseno longer carries body bytes — it was previously one arbitrary page's fragment, not the query's data.chore(utils): deprecateformat_datetime. A dead-function sweep over the whole package found exactly one orphan: it shaped qw-service responses, lost its last caller in491eb5c3, and appears in no docs or demos. Public name, so it warns through_deprecation.warn_deprecatedwith a 2027-08-22 horizon instead of vanishing.Two cleanup passes (each: 4 parallel review agents across reuse / simplification / efficiency / altitude) also landed on the branch. The second pass replaced the direct
shapely.pointscall withgeopandas.points_from_xy— the idiomnwisalready uses, retiring the package's only directshapelyimport — and gated_properties_frame's Python dict scan behindinfer_dtype, worth 2.3x on that helper under pandas 2 (supported by this package, and where every string column is object dtype).Efficiency benchmarks (current branch head)
get_daily, median of 7 trials/armCorrectness: outputs verified identical (
assert_frame_equal) against recorded API responses for full/missing/mixed geometry, polygons, nested properties, 3-D coordinates, single-feature pages, and a properties-levelidcolumn; full suite (1004 tests) andmypy --strictpass.Evaluated and rejected (with numbers)
DataFrame(records)): 1.08x best case — pandas 3's record path is already good.cursor=<opaque>;numberMatchedabsent fordaily), so page N+1's URL cannot be constructed ahead of page N.MRE
Offline, deterministic, no API key or quota (compares against the pre-change implementation inlined):
Typical output on an M-series laptop:
from_features=554ms vectorized=116ms (4.8x, identical output).Methodology notes
httpx.MockTransport, sleeping each request's recorded wall time — exact pairing, no quota, no cache noise.tracemallocpeak, one arm per process, response bodies freshly allocated per request.🤖 Generated with Claude Code
https://claude.ai/code/session_01UKweWmHg8cu1WuJ17kiHdh