feat: expose native Parquet scan I/O and read-amplification metrics - #5453
feat: expose native Parquet scan I/O and read-amplification metrics#5453sunchao wants to merge 2 commits into
Conversation
|
This is a first pass review using an LLM. I will also review manually. Thanks for this. The layering is well thought out and the split between what the reader receives and what a remote store actually services is genuinely useful. The description is the clearest explanation of Parquet read amplification I have seen in this repo. My main request is that we split this into three PRs. The object store registry isolation in On Could you add the nine metrics to Two things about the isolated registration URL. It is built from What does The scheme allowlist in Could The data versus metadata split relies on parquet-rs using
|
|
Thanks, @andygrove ! splitting this makes sense. I’ll move the object-store isolation fix and producer-shutdown change into separate PRs with their own tracking issues, and keep this PR focused on scan I/O metrics. Leaving You’re right about the encrypted-footer wording. That path records a complete footer payload before decryption and validation, so the description overstates the guarantee. I’ll clarify the semantics and add coverage for corrupt encrypted footers. I’ll also document the footer protocol and read-method assumptions, consolidate backend classification, and strengthen the HDFS test so it checks actual range-read delegation. A few details from checking the implementation:
For producer shutdown, the wait can improve the final metrics snapshot, but it does not guarantee complete accounting of in-flight work. I agree the latency tradeoff needs separate evidence. I’ll address that, along with the shared-runtime test concern, in the separate PR. |
This is a genuinely useful capability. Being unable to tell "128 bytes of data pages" apart from "a 512 KB coalesced GET" is a real gap when you are debugging a slow scan against object storage, and the layering into reader boundary, object-store boundary, and cache boundary is the right decomposition. The native test coverage is unusually thorough for a metrics change. That said, I have several concerns that I think need resolving before this merges.
For the I understand why it is written this way, since delegating to the inner store would hide the coalescing that the metrics exist to expose. Is there a way to instrument the coalescing without owning it? If not, this tradeoff should at least be spelled out in a comment at the
The
What happens on the give-up path? The context is dropped shortly after, while a task that has been asked to abort but has not yielded may still hold references into it. If that is safe, why is it safe? If it is only safe because 100ms is empirically enough, that is a race, not a bound. Second, why is this in a metrics PR at all? The description says cancellation should not leave background work distorting published metrics, but this is a change to plan teardown semantics and it deserves its own PR and its own review. Blocking a Spark task thread for up to 100ms per plan release is not free either. Could this be split out? Nine always-on SQL metrics per scan Every entry in the scan metric map becomes a driver-side accumulator per task and a line in the SQL UI node. Going from one
That page is hand-maintained and has a The remote-scheme allowlist will silently under-report
Encryption
|
|
Updated in 130ee02bb. The two reviews are addressed in this revision as follows:
Validation for the follow-up: 100 native Parquet tests and 12 Spark 4.1.3 tests passed, including all ten task-metrics tests, with the newly built metrics native library. The full JVM reactor, ScalaStyle/Spotless, native library/test Clippy with warnings denied, Rust formatting, and metrics-page formatting passed. Broader cross-version results are explicitly labeled as earlier validation rather than reruns of this revision. |
|
Two current CI failures at The Linux Spark 3.5 scans job stopped during Maven dependency resolution with HTTP 429 for The macOS Spark 4.0 scans job reached the tests, then suffered a native The macOS crash is not a passing test result, and I have not claimed a clean macOS run. The parent workflow is still active. The fresh local native and Spark metrics results in the description remain separate from these hosted checks. |
andygrove
left a comment
There was a problem hiding this comment.
The footer accounting, the encrypted-footer disclosure, and the scheme classification all address what I raised earlier. The comment block above record_returned in eager_page_index_reader_factory.rs documents the tail/payload protocol well enough that a future reader doesn't have to re-derive it from the ParquetMetaDataPushDecoder loop. counts_complete_encrypted_footer_even_when_authentication_fails covers the case where a corrupted encrypted footer's I/O still counts before authentication runs, which matches the corrected wording in metrics.md.
Moving the backend classification into object_store_backend in parquet_support.rs, keyed off ObjectStoreScheme::parse instead of a hand-rolled scheme list, resolves the drift concern too. I checked that the schemes it can't classify, gcs, wasb, wasbs, and s3n, are exactly the ones that already fail a few lines later when prepare_object_store_with_configs tries to build the real store through the same parser. So there's no case where a scan works but silently loses its object-store metrics.
metrics.md now covers all nine counters, says plainly that reader-level and object-store bytes are alternative views that should not be summed, and gives the zero-denominator guidance for metadata-only scans. I traced through the case where a remote scan's metadata fetch wraps an already ObjectStore-role store, since that is where I would expect double counting to show up, and it composes correctly. The outer wrapper records logical bytes once per range, the inner one records the coalesced physical GET once, and neither adds the other's bytes into its own total.
Splitting the object-store isolation fix and stop_batch_producer into separate PRs was the right call, and neither shows up in this diff anymore.
|
@sunchao this is conflicting with main now. Could you rebase? Everything was green beforehand. |
130ee02 to
7944206
Compare
|
Just rebased @andygrove |
|
The rebase brought in more than a replay, and one piece of it interacts with the feature this PR adds. Against let url = normalize_object_store_url(url.as_str(), object_store_configs)?;
let is_hdfs_scheme = is_hdfs_scheme(&url, object_store_configs);That ordering, from #5314, classifies libhdfs routing against the rewritten scheme, so an I filed that as #5816, and it turned up while chasing the failure on #5503, whose The reason it matters here rather than only there is that if is_hdfs {
return Ok(ObjectStoreBackend::Other);
}So under that configuration an This does not change my view of the metrics work, and I am not asking you to widen this PR's scope. But since #5453 depends on #5503 by your own note, and #5503's test is the natural regression guard for #5816, could the ordering fix land in #5503 and this rebase onto it? Then My approval stands for everything else. |
797ba78 to
30e78e3
Compare
30e78e3 to
1cce3b2
Compare
Which issue does this PR close?
Closes #5508.
The original-scheme routing fix (#5825) and deterministic backend/cache isolation and encryption URI fix (#5503, tracking #5502) have merged into
mainand are included in this rebase. Producer cancellation and final-snapshot waiting remain separate in #5505 (tracking #5504). This PR contains the scan metrics and their documentation/tests. The shutdown PR is independent of the metrics definitions.Why are the changes needed?
Column projection and predicate pruning can make a Parquet scan appear inexpensive while the underlying storage still performs substantially more I/O. The scan needs more than the selected data pages: it may also fetch a footer, page indexes, and Bloom filters. Separately, the object-store reader can merge several small logical ranges into a much larger physical GET. Existing scan metrics do not distinguish these layers, so they cannot explain whether a slow or expensive scan is caused by actual data, metadata, range coalescing, or ineffective metadata caching.
Consider the deterministic range-coalescing case covered by this PR. The Parquet reader requests two 64-byte ranges, but the object-store layer combines them into one much larger GET:
Without an object-store-boundary measurement, both a genuinely efficient 128-byte read and this 524,416-byte read can present the same
bytes_scannedvalue. Projection and predicate pushdown may therefore look effective while the expensive part of the read remains invisible.Metadata creates a different blind spot. A metadata-only scan can read a footer, page indexes, or Bloom filters without returning a single projected data page. On the next scan, the same metadata may be served entirely from cache. Previously there was no reliable way to distinguish "no data pages were needed," "metadata still required storage I/O," and "metadata was already cached."
What changes were proposed in this PR?
The change introduces an end-to-end I/O accounting model with two deliberately different observation points: what the Parquet reader actually receives, and what a recognized remote object store actually services. These measurements are exposed through existing native execution metrics and propagated to Spark SQL metrics without changing the meaning of
bytes_scannedor adding per-row instrumentation.At the Parquet reader boundary,
scan_io_data_bytesmeasures returned projected data-page bytes, whilescan_io_metadata_bytesmeasures returned footer-prefetch, page-index, and Bloom-filter bytes. This separates useful projected data from the metadata needed to open and prune a file.scan_io_footer_readsandscan_io_footer_bytesfurther identify how often a serialized footer payload was actually read from storage and how large that payload was. Footer bytes are already included in metadata bytes; they are a more specific breakdown, not another category to add to the total.At the remote object-store boundary,
scan_io_object_store_get_callscounts GET operations after range coalescing,scan_io_object_store_get_requested_bytesrecords the coalesced ranges requested, andscan_io_object_store_response_bytes_readrecords response bytes as they are actually consumed. The coalescing example above therefore becomes directly observable: 128 reader-visible data bytes, one object-store GET, and 524,416 requested and consumed response bytes. Comparing object-store response bytes with projected data bytes reveals read amplification; comparing requested bytes with consumed bytes also distinguishes a fully consumed request from an early-terminated response.This boundary is intentionally precise: the object-store metrics describe the
ObjectStoreAPI, not HTTP wire bytes, lower-level retries, compression, or transport implementation details. Their classification comes from the backend selected by object-store construction, using the pinned parser rather than a separate scheme allowlist. This includes native Azureazure/adlaliases. Local filesystem reads, in-memory stores, and HDFS/custom backends retain reader-level counters and have zero remote counters. The pinned parser does not accept the proposedgcs,wasb,wasbs, ors3nnative aliases. The merged #5503 includes backend identity in the cache key, so a colliding cached store cannot contradict the selected classification.At the metadata cache boundary,
scan_io_metadata_cache_hitsandscan_io_metadata_cache_missesclassify successful metadata loads according to whether storage was actually read. For example:Together, these layers answer separate questions without double counting them: what reached the Parquet reader, what crossed the remote object-store API, and whether metadata access required storage at all. In particular, reader-level bytes and object-store bytes are alternative views of the same read path, not values that should be summed together. Metadata-only scans have no projected-data denominator, so their useful diagnostic is metadata and object-store traffic rather than an amplification ratio.
Footer accounting follows the metadata decoder protocol and is documented at the recording and read-method boundaries. A plaintext payload must decode; a subsequent page-index failure does not undo that footer read. A complete encrypted payload is counted before key retrieval, authentication, or metadata validation, so corrupt encrypted payloads can contribute footer bytes and a footer read. Incomplete payloads do not count. The new corrupt-authentication-tag regression makes this distinction explicit.
The native remote wrapper deliberately repeats the current cloud stores' default range coalescing so it can observe physical API requests. It therefore bypasses an inner
get_rangesoverride; a future cache or custom implementation needs an explicit accounting contract. New recording-store tests check both that behavior and actual local/custom range delegation. This is documented as a composition constraint, not silently treated as universal support for arbitrary wrappers.bytes_scanned, taskinputMetrics.bytesRead, andscan_efficiency_ratioretain their existing meanings and blind spots. The new counters expose the missing I/O without redefining those existing metrics. The user guide now defines all nine counters, their overlap, supported backends, cache/encryption behavior, and the zero-data denominator. Cancellation can still leave late asynchronous updates outside the final snapshot; this PR does not promise complete traffic accounting after cancellation.How was this PR tested?
September 10 rebase after #5503
Rebased both commits onto
bdd4b90b49751b2ffedbddf87805b5f6311e9dfa, which includes #5825 and #5503. Resolved the overlaps inparquet_support.rsandparquet_exec.rswhile preserving original-scheme routing, backend/configuration cache identity, deterministic registration, and encryption URI normalization. The Bloom-filter API correction is unchanged.Andy’s routing concern is covered at both normalization and preparation boundaries. The normalization regression checks
s3aand configuredblobaliases withfs.comet.libhdfs.schemes=s3. The merged isolation tests now also assert the returned I/O classification: native S3 isRemote, Hadoop routing isOther, and native files areLocal. The S3/Hadoop tests verify distinct cached stores and their contents in both registration orders. No isolation prerequisite remains outstanding.Validation at
1cce3b2b4f9df7b1124aa8cc9f1297eb7c9e243f:cargo test --locked --no-default-features -p datafusion-comet --lib parquet::. This includes the original-routing and cache-classification regressions, coalescing/delegation, footer accounting, and encryption URI tests. The previously missing registry dependency is now available; no dependency manifests or lockfile changes were needed../mvnw -B -ntp -Pspark-4.1 -DskipTests test-compile. This command did not execute Spark tests.The previous head’s macOS Spark 4.0 scans failure was a native crash after the fake-filesystem test passed. Symbolizing the exact consumed artifact resolves the saved return address to
hdfsThreadDestructor + 0x50, matching the existing teardown issue #5023 with proposed fix #5036. This identifies the failure; it does not count as a passing macOS run.September 10 rebase before #5503 (historical)
Rebased both commits onto
2d3eca2100d8d8684d31c496b376388a5fd79f18, including the original-scheme routing fix from #5825. The metrics classification now consumes the libhdfs decision carried by URL normalization. Added regression coverage fors3aand configuredblobaliases withfs.comet.libhdfs.schemes=s3, and updated the preparation test to assert the returnedRemoteclassification. The existing Bloom-filter API correction was preserved. #5503 had not yet merged at this validation point../mvnw -B -ntp -Pspark-4.1 -DskipTests test-compile. Tests were not executed by this command.object_store 0.13.2andurl 2.5.8. This is supplemental validation of those helpers, not a full native build.cargo test --locked --no-default-features -p datafusion-comet --lib parquet::parquet_support::tests:: --no-runremains blocked before source compilation: the configured registry does not provide lockedaws-smithy-runtime-api 1.16.0. Native manifests and the lockfile are unchanged from the new base. Full native tests and Spark tests using a newly built library require fresh CI validation.September 9 rebase validation (historical)
Rebased the final reviewed patch onto
424c31aa79d13fddf743ffa29bae3c6f146e6c5e, consolidating the review iterations into one commit. The resolutions preserve DataFusion 55's concrete metadata cache and direct object-store reader APIs, the removed legacy JNI reader, Unicode name folding, and S3-compatible alias normalization. Both newer dynamic-filter test callers now pass the selected local backend. #5503 had not yet merged at this validation point../mvnw -Pspark-4.1 test-compile -DskipTests. This compiled the scan-metrics tests and accumulator benchmark; it did not execute tests.cargo test --locked -p datafusion-comet --lib parquet:: --no-run, but dependency resolution failed before source compilation: the local registry mirror did not provideaws-smithy-runtime-api 1.16.0, required by the existing lockfile. The native dependency manifests and lockfile are unchanged from the new base. Native compilation, native tests/Clippy, and Spark tests using a newly built native library remain unverified for this rebase and require fresh CI results.August 27 validation (historical; predates the rebase)
For the August 27 review follow-up, the production native library was built with default features and a full JDK 21. All 100 native Parquet tests passed, including corrupt encrypted payloads, default remote coalescing, and actual custom/local range delegation. Native Clippy for the library and tests passed with warnings denied. Rust formatting, the changed metrics page's Prettier check, and the PR diff check passed.
The complete Spark 4.1.3 JVM reactor compiled and passed Spotless/ScalaStyle using the newly built metrics native library, whose copied resource was verified by SHA-256. Twelve Spark tests passed: native scan metric propagation, collect-limit behavior, and all ten
CometTaskMetricsSuitecases. These are fresh tests of this revision's production code, not a reused unrelated native library.The accumulator benchmark is part of
CometReadBenchmark. It runs real Spark jobs with 10,000 tasks, comparing zero versus nine extra SQL accumulators (five size counters and four ordinary counters), and verifies all nine final values after each job. Each task adds 64 to each extra accumulator. Two fresh JVM runs reverse the case order; each case has warmup and three measured job iterations. Spark 4.1.3/JDK 17 ran on AMD EPYC-Milan withlocal[1], no competing workspace builds/tests, and no storage work.That is approximately 41–52 microseconds per task on this deliberately tiny-task fixture. This measures additional accumulator serialization, scheduler reporting, and merging in a local job; it is not an end-to-end Parquet scan benchmark or a distributed-network result. Native counters, JNI traversal, SQL UI rendering, and storage I/O are excluded. The driver creates these accumulators per scan operator, not one new driver metric identity per task. A separate 1,024-operator creation loop observed best times of 4.19–4.43 microseconds per group of nine, with considerable GC variation in the averages. The in-process copy/merge loop is only a JVM microbenchmark and can benefit from escape analysis; its cost is not substituted for the real-task result.
The metrics remain enabled with the other scan metrics. This measurement shows a nonzero cost and does not claim that cost is free or that the same percentage applies to real scan tasks.
To reproduce the accumulator measurement from the repository root (after the normal release build prerequisites are available):
The broader validation reported before this follow-up included native suites with and without default features and Spark 3.4/3.5/4.0/4.1/4.2 coverage. Those earlier cross-version results are historical; this follow-up reran the default-feature native tests and Spark 4.1.3 coverage described above. The extracted PRs describe their own current validation and cancellation measurements separately.