Spark: Add selective shredded variant extraction Parquet readers#16714
Spark: Add selective shredded variant extraction Parquet readers#16714qlong wants to merge 4 commits into
Conversation
8fdff7c to
2185f2a
Compare
|
@rdblue @steveloughran @nssalian PTAL when you get a chance. |
| } | ||
|
|
||
| @SuppressWarnings("checkstyle:CyclomaticComplexity") | ||
| private static Object toSparkValue(VariantValue value, DataType targetType) { |
There was a problem hiding this comment.
I may be missing something, but this looks like it overlaps with Spark's variant_get casting logic. toSparkValue handles the common cases, but it seems separate from Spark's existing behavior around failOnError, timeZoneId, and some cast edge cases.
Would it make sense to reuse Spark's cast path here if we can bridge from Iceberg's VariantValue to Spark's Variant / VariantVal?
There was a problem hiding this comment.
Thanks for review. I assume you were referring VariantGet.cast in spark. toSparkValue in the connector is required according to DSV2 extraction pushdown contract. When Spark pushes extraction down, it delegates extraction and cast to the connector, the engine no longer calls VarianGett.cast one the values returned from connector. The bridge from iceberg's VariantValue to spark's Variant already exists, it is triggered when extaction pushdown was rejected and connector returns the whole variant. It is expensive for shredded typed value, as they are put back into VariantValue then immediately extraced by Spark again.
The cast logic in Spark is more general than needed here, it handles cross-type coercisons that do not apply to typed_value, with the exception of type narrowing overflow. I added a fix for that.
There was a problem hiding this comment.
I see, that makes sense, thanks for the explanation. Could you add a short method comment here explaining why this uses a narrow custom conversion instead of Spark’s VariantGet.cast?
There was a problem hiding this comment.
One more thing about failOnError here, it looks like it is not taken into account.
Spark exposes two functions that map to VariantGet: variant_get (failOnError = true, throws on an invalid cast / overflow) and try_variant_get (failOnError = false, returns null). As written, toSparkValue returns null on overflow and type mismatch in both cases, so it seems to me this would behave like try_variant_get even when the user called variant_get.
There was a problem hiding this comment.
failOnError and timeZoneId are present in the extraction metadata (see the extractionField test helper), but extractionPath() reads only path, so they're dropped. On the gate side, #16715's pushVariantExtractions accepts an extraction based on path and target type alone, and isSupportedPushdownTargetType(DataType) only sees the type, so there's no point today where failOnError=true is rejected. That means variant_get (failOnError=true) gets routed through toSparkValue, which returns null on overflow and type mismatch, so it behaves like try_variant_get.
The fix is either to decline pushdown when failOnError=true, or to thread the flag through and throw on the overflow/mismatch branches. Declining is simpler since the readers stay try-only. A failOnError=true overflow test asserting a throw would mirror the existing ...ReturnsNull ones.
There was a problem hiding this comment.
Good call out on missing support for failOnError. Changed it so the cast behavior matches Spark:
- pass failOnError flag around
- For trivial conversion, use fast inline conversion for performance
- Delegate to Spark's own cast for other conversions. This path has a around 1KB overhead in VariantCastArgs, which is high for primitives.
Pass timezoneId around timezone sensitive cast (in delegation path).
| import org.apache.iceberg.relocated.com.google.common.collect.Lists; | ||
| import org.apache.iceberg.relocated.com.google.common.collect.Streams; | ||
|
|
||
| public class PathUtil { |
There was a problem hiding this comment.
If I am not mistaken, this utility is only used for variant extraction/shredding paths. Should we rename it to something like VariantPathUtil or VariantPath so it is clear this is not a general Iceberg path utility?
There was a problem hiding this comment.
You are correct, agree that VariantPathUtil is a better name. I am going to defer the change for now, since this file is copied from #15384 to avoid stacked PRs. There are other in-fligh PRs that also copy this file. I will put up a follow up PR to rename once they are merged.
| if (segment instanceof PathSegment.Name) { | ||
| parts.add(((PathSegment.Name) segment).name()); | ||
| } else if (segment instanceof PathSegment.Index) { | ||
| parts.add("[" + ((PathSegment.Index) segment).index() + "]"); |
There was a problem hiding this comment.
I think this loses some path semantics. PathUtil.parse distinguishes object names from array indexes, but parseObjectPath flattens both into string parts. For example, $[0] means array element 0, while $['[0]'] means an object field whose name is literally [0]; after flattening, both are represented as the same string segment "[0]".
There was a problem hiding this comment.
Good call out. I removed parseObjectPath, and pass List through the Parquet extraction readers. Added tests
| import org.apache.iceberg.relocated.com.google.common.collect.Lists; | ||
| import org.apache.iceberg.relocated.com.google.common.collect.Streams; | ||
|
|
||
| public class PathUtil { |
There was a problem hiding this comment.
Slightly broader question: would it make sense to use an existing JSONPath parser here and then validate that the parsed path is within Iceberg's supported subset? This is probably the fourth project where I have seen a new VariantPath-style implementation over the past year, so I am a bit worried about adding another one unless we keep the scope very explicit.
For example, we could allow simple/singular paths like field access and array indexes, while rejecting wildcards, recursive descent, slices, and filter expressions for now.
There was a problem hiding this comment.
My understanding is iceberg has strict dependency hygiene, adding new lib would require review and could add transtive dependences. The parser from #15384 is intentionlly minimal.. We probably should look into dedicated lib if we need to support wildcard, filter expressions.
4de911e to
4227e75
Compare
4a75e46 to
5617bd7
Compare
tmater
left a comment
There was a problem hiding this comment.
I like the direction of separating Spark variant_get semantics from the optimized selective Parquet read path. One thing that still makes this PR difficult to review is that it introduces both the pushdown/delegation plumbing and the custom primitive fast path at the same time.
Would it make sense to split this into two PRs?
- First PR: wire the selective reader path but delegate all Spark type conversion to Spark’s
VariantGet.cast, so the new path is easier to validate against existing Spark semantics. - Follow-up PR: add the primitive inline fast path as a focused optimization, with benchmarks and targeted equivalence tests for the pairs it owns.
| return objectPathParts.isEmpty() ? variantGroup : null; | ||
| } | ||
|
|
||
| GroupType current = typedValue.asGroupType(); |
There was a problem hiding this comment.
I think this mishandles root extraction ("$"). Eaerlier PathUtil.parse("$") returns an empty path, and for a shredded object resolveShreddedFieldGroup(variantGroup, emptyPath) returns the root typed_value group. That group contains object fields, not a variant value group with its own value/typed_value, so the leaf-reader check below fails and no reader is built unless the root serialized value fallback exists.
There was a problem hiding this comment.
@tmater I like the simplicity of delegation, but I ran micro performance and found the overhead (memory allocation) was quite high for delegating to spark cast. Let me run some end to end perf tests first, will share the numbers here before we decide?
|
@tmater Here is 1 e2e test runs using 1-days Github activity data. partial delegation is what the current state of PR, full means all cast is done by Spark. 1 extaction per query. c-q05, c-q05b, c-q08 all extract to long, they show biggest performance gain. c-q09 extract boolan, all other queries extract a string value, they do not show much difference. I think in the long term fast inline cast is worth it at least for numbers, more so if more then 1 extraction per queries. But I am open to remove the inline cast and related cross check tests for now if you think the PR is already too big. If we go that way, we just need to keep inline cast for nanoseconds until spark supports it. |
|
FWIW I'd like my jmh benchmark #15629 in alongside/before this, they are independent but the final predicate pushdown really needs it to show speedup. even this should show before/after speedup. |
- Add selective Parquet readers (ParquetVariantExtractionReaders, VariantExtractionPathResolver) to read only shredded typed_value columns for requested extraction paths. - Add Spark row reader adapter (SparkVariantExtractionReaders, SparkParquetReaders) to materialize extraction slots from the engine read schema instead of full variant blobs. - Wire engine read schema from SparkBatch through SparkInputPartition to RowDataReader only (row Parquet path). - Update PruneColumnsWithoutReordering so annotated extraction structs map back to Iceberg VARIANT columns in the scan projection. Issue: apache#16726
Address review feedback: - Remove parseObjectPath and related PathUtil string helpers so PathUtil stays aligned with the companion pushdown branch. - Pass List<PathSegment> through the Parquet extraction readers, path resolver, and Spark wiring to preserve array index vs object fieldq semantics during navigation. - Return null if the target type is narrower than the value type and overflows.
Address review feedback on failOnError and cast coverage: - Honor failOnError: variant_get (failOnError=true) now throws INVALID_VARIANT_CAST on integer narrowing overflow instead of silently returning null like try_variant_get. Pass failOnError from the extraction metadata through to the cast. - Delegate casts outside the inline fast path to Spark's VariantGet.cast so cross-type and lossy casts (string->long, double->long, decimal rescale, double->float overflow, TZ<->NTZ) match the non-pushdown variant_get path exactly instead of returning null. - Keep a narrow inline fast path for the common, stable pairs (identity unwraps, integer range-checks) and for physical types Spark's variant cannot represent (TIME, nanos timestamps), which must be handled here. - Pass timeZoneId for zone-sensitive delegated casts, falling back to the session time zone when absent. - Add a cross-check test asserting the inline pairs produce the same result as VariantGet.cast so the two paths cannot silently diverge.
5617bd7 to
40f386c
Compare
Address review feedback on root extraction: - Handle empty extraction paths ($) in buildShreddedLeafReader by reconstructing the full shredded variant via buildRowCachedReader instead of falling through to resolveShreddedFieldGroup. Previously a shredded object resolved to the typed_value object-fields group, which has neither typed_value nor value, so the leaf reader returned null and the path silently degraded to the slow root serialized-value fallback. - Share the root-reconstruction reader across $ and every $[n] array-index path (all keyed on the empty path) so the full root value is read and rebuilt only once per row. - Add root path tests: shredded object, unshredded fallback, and shredded array of objects. - Add an explicit $[0] first-element test on a root array to document zero-indexing (complements the existing $[1] case). - Add a heterogeneous root-array-of-objects test that shreds via the production VariantShreddingAnalyzer (majority-vote per field, union of fields) rather than ParquetVariantUtil, confirming non-uniform array elements still shred and per-element field presence is honored.
40f386c to
71d8454
Compare
Changes
This PR is part of the work to support variant extraction pushdown, the core change is to introduce new parquet readers to read selected variant paths instead of the whole variant:
Issue: #16726
Note for reviewers
End to end testing
Requires #16715 for end-to-end testing. To try the full pushdown + selective read path without merging locally, use this branch:
https://github.com/qlong/iceberg/tree/variant-extraction-integration-test
Test results
Use 1-day Github activities data, ingested as json and shredded variants with 299 shreddred columns.
Baseline:
gha-payload-iceberg-20260605· variant + extraction pushdown ON + selective shredded variant extraction Parquet readersCompare A: same run with payload stored as
string_jsonCompare B:
gha-payload-iceberg-nopushed-20260605· variant + pushdown OFF, read whole variantMedian of 3 timed runs per query (Spark
Time taken:).Co-authored with Claude Sonnet 4.6