Introducing the VARIANT Type in Velox #17033
Replies: 17 comments 3 replies
|
cc: @Yuhta @mbasmanova @pedroerp @rui-mo |
|
Looks great! Velox should support this data type. |
|
Thanks for proposing this extension to Velox! A few questions that would help us evaluate the design: What queries benefit? It would be great to include a few representative queries and workloads that would benefit from this, ideally with some performance numbers comparing shredding vs. binary navigation. This would help us understand the practical impact and prioritize the work. Similarity to subfield pruning — could this be a custom type on top of ROW? Shredding seems closely related to the existing subfield pruning mechanism in Velox, where the planner collects which parts of a complex type (ROW, MAP, ARRAY) are accessed and tells the reader what to materialize instead of returning everything. Shredding is conceptually the same — the planner identifies which paths within the semi-structured data are accessed and the reader materializes those as typed columns. See also #16968 for related work on extraction pushdown into scan specs. This suggests the shredded schema could be determined at query planning time, based on which fields the query accesses — similar to Spark's
If this approach works, it would avoid adding a new Naming Worth noting that |
|
Hi @mbasmanova, let me address the naming question first — I'll follow up on the other two questions tomorrow. Here are a couple of alternatives I've considered:
My initial preference is
So However, I do have a concern with this choice: every other engine and lakehouse format in the ecosystem calls it So perhaps the cleanest path is to keep |
|
Thanks for the benchmarks and detailed response. A few follow-up questions: Different files having different shredded columns — You emphasize this as a key reason for runtime-dynamic shredded schemas. But if Subfield pruning is the same mechanism — You note that shredding is a write-time decision while subfield pruning is a query-time optimization. But the reader-side mechanism is the same: the planner tells the reader what columns to materialize. The reader reconciles that with what's physically available in each file. This is exactly how subfield pruning works today for ROW/MAP/ARRAY — some files may have different nested structures, and the reader handles it. Field access doesn't need Variant at all — For Full Variant queries — For queries that need the complete data ( In neither case does the execution pipeline need to carry shredded columns alongside the blob. |
|
@mbasmanova Does it make sense to you if we begin with a custom type PR as Phase 1, mainly to unblock basic support, and defer the built-in Variant decision until after we have benchmarking data?
|
|
I don't see a point in carrying heterogeneously shredded data through the execution pipeline. If the scan produces data whose shape keeps changing from batch to batch, query performance will be terrible — expressions assume a stable schema. The query knows which columns it needs. The scan should produce exactly that — using shredded columns when available, falling back to binary parsing when not. The shredding is a reader-level optimization, invisible to the rest of the pipeline. This is how subfield pruning already works for ROW/MAP/ARRAY. What am I missing? |
|
Hi @mbasmanova Thanks for the explanation I see — carrying heterogeneous shredded data through the pipeline is the wrong boundary. Confirmed by reading Spark's SELECT
variant_get(data, '$.user.id', 'bigint') AS uid,
variant_get(data, '$.event.type', 'string') AS etype,
to_json(data) AS raw
FROM tis rewritten as: So the reader's contract is "produce one column per requested access pattern, with stable types". Shredding reconstruction, when needed for the full-variant slot, happens inside the reader. Nothing heterogeneous leaks into the pipeline. I'll align Velox's design with this model:
Plan to drop the RFC and send small PRs. Let me know if you'd rather see a short design note first. |
|
Thanks for aligning on the design. One question: why do we need VARIANT as a custom type over |
|
Some runtime consumers still need to navigate (metadata, value) per row:
case g@VariantGet(v@StructPathToVariant(fields), path, _, _, _)
if path.foldable =>
GetStructField(rewriteAttribute(v), fields(RequestedVariantField(g)))When the path is dynamic, the rewrite doesn't fire and
WDYT? |
|
Thanks for the explanation. A few more design questions: Package dependency. Up until now, custom types in Velox have been defined by function packages and only understood by functions. Operators and readers/writers do not know about custom types — they see only the underlying physical type. Here, the VARIANT custom type would need to be understood by the Parquet reader/writer in addition to Spark functions. Having Parquet depend on Spark or Spark functions depend on Parquet would be undesirable. This likely needs its own package to define the type, separate from both. Data lifecycle. How does VARIANT data get created in the first place? In Spark, there are functions like Relationship to JSON. Spark's existing JSON functions ( We are generally aligned on adding Variant support to Velox and appreciate the effort you've put into this proposal. Please go ahead with prototyping and share the design as it emerges — we'd love to follow along and provide feedback as these questions get resolved. |
|
@baibaichen Thanks for the proposal. Support column stats is one of the key benefit of variant type, could you help to explain how variant column stats are been collected especially for non-shredding column? This could help us understand/review the potential And it seems parquet C++ does not support reading and writing variant type yet. |
@mbasmanova / @baibaichen - do we have any PR or prototype for this in velox already, so this can be supported in Nimble? |
|
Hi @baibaichen, thanks for this great proposal. Do we have tentative timeline for the prototype? Looking forward for it and we can collaborate on it |
Uh oh!
There was an error while loading. Please reload this page.
1. Motivation
The
VARIANTtype provides an efficient binary representation for dynamic semi-structured data. Engines such as Databricks, Snowflake, Spark, DuckDB, and StarRocks, together with table formats such as Apache Iceberg and Apache Paimon, already supportVariant. It has become the de facto semi-structured type in lakehouse systems.Parquet
Variantand its Shredding encoding preserve type information,typed_value, and column statistics. This behavior differs fundamentally from the traditionalJSON-as-VARCHARfull-text parsing path. From the perspectives of both performance and structural consistency, Velox needs a dedicatedVARIANTtype to represent this upstream physical format.2. Technical Background and Upstream Ecosystem
2.1 Parquet
VariantFormat and ShreddingParquet
Variantis not a simple wrapper around JSON text. It is a well-defined binary format. AVariantvalue consists of two parts:metadataandvalue. The former stores the field-name dictionary. The latter stores the recursive encoding of types and values. With this format, the reader can resolve child fields by path directly, without falling back to text parsing.In addition, Parquet defines the Shredding specification. Shredding splits frequently accessed fields out of the
Variant blobinto independent strongly typed columns. This changes the physical representation from a singlevalue blobto a composite layout ofmetadata + remainder value + typed columns. That change directly affects the structural design ofVARIANTcolumns in Velox.2.2 How Spark Uses It
Spark 4.1.x uses two execution paths for
Variant.The first is the optimized path. When a query uses only path-based access such as
variant_get, Spark's optimization rulePushVariantIntoScanrewrites those accesses into projections of specific child fields and pushes them down into the Parquet reader. The reader then decodes theVariantvalue and returns it as aStruct. This effectively eliminates theVarianttype at scan time, so the rest of execution uses only structured columns.The second is the full-return path. Some functions, such as
to_json, semantically require the completeVariantvalue. In that case, even when theVariantfield has already been split into shredded columns, Spark must still re-encodetyped_columnstogether with theremainder valueto reconstruct themetadataandvalueof theVariant.For vectorized engines such as Gluten + Velox, directly adopting Spark's strategy would require the full-return path to re-encode shredded columns. That introduces significant overhead. Therefore, the Velox design must also preserve efficient full-
Variantreturn.2.3 Velox's Existing Type Extension Mechanisms
Velox's type system includes physical types and the logical, custom, and complex types built on top of them. A physical type is a fixed built-in scalar type such as
BOOLEAN,INTEGER,BIGINT,VARCHAR, orVARBINARY. It determines the in-memory layout. Each physical type corresponds to one C++ type used as the template parameter ofFlatVector<T>. Complex types include the three built-in composite typesARRAY,MAP, andROW. Velox provides three extension paths:DATE(INTEGER),DECIMAL(BIGINT/HUGEINT)JSON(VARCHAR),GEOMETRY(VARBINARY),IPPREFIX(ROW(HUGEINT, TINYINT))std::shared_ptr<void>3. VARIANT Type Design
None of the three extension mechanisms described above can satisfy the requirements of
VARIANT:Logical Type and
VARBINARY-based Custom Type can store only a single blob. They cannot represent the multi-column structure ofmetadata + value + shreddedColumns, which would effectively forfeit Shredding.ROW-based Custom Type, such asIPPREFIX, requires the number ofchildrento be fixed at construction time. In addition,RowVector::copy()assumes that children are aligned by index. In contrast, shredded columns inVARIANTare dynamic at runtime.OPAQUEstores values row by row asstd::shared_ptr<void>. It therefore cannot be vectorized.Therefore, Velox must introduce
VARIANTas a built-in type. The core of the design is the newTypeKind::VARIANTenum together with the correspondingVariantTypeandVariantVectorimplementations.Note
If
VARIANTdid not require Shredding, and consisted only of the two fixedVARBINARYcolumnsmetadataandvalue, then modeling it asROW(VARBINARY, VARBINARY)through a Custom Type would be sufficient, much likeIPPREFIX. However, Shredding is the key performance feature of the ParquetVariantspecification. Dropping it would forfeit an order-of-magnitude performance improvement.3.1 Core Definitions
VARIANTis added as a first-class member of the Velox type system.The corresponding type traits, type class, and vector class are defined as follows.
The corresponding
Encodingis:Type constraints: both
isComparable()andisOrderable()returnfalse. AVARIANTvalue cannot participate inORDER BY,GROUP BY, join keys,DISTINCT,INTERSECT,EXCEPT, orREPARTITION BY. This is not a temporary MVP compromise. It follows naturally from the absence of general comparison semantics for semi-structured types. Spark 4.1 also disallowsVARIANTin all of these operations through SPARK-47569, SPARK-48224, and SPARK-47822.Zero-schema principle:
VariantTypedoes not exposechildAt()ornames(). It is fundamentally different fromROW<a:int, b:string>. For semi-structured data, hierarchy and field names exist only inside themetadataandvaluestored in column data, following a schema-on-read model. Path visibility, strongly typed fast paths, and shredded schema all belong to the runtime state ofVariantVector, not to the staticTypedefinition.3.2 Binary Compatibility with Parquet
VariantEncodingThe binary encoding of Parquet
Variantoriginated in the SparkVariantspecification and was later adopted formally by Parquet. The Iceberg Variant proposal compared several alternatives, including BSON, Amazon ION, PostgreSQL JSONB, and the Spark encoding, and ultimately selected the Spark encoding as the standardVariantencoding for Iceberg. Its core advantages are the internal field-name dictionary and offset arrays. These structures support binary search andO(1)jumps, and they eliminate the need to build an additional index by traversal. BSON and ION do not provide this structure.The value representation is also more compact. Integers can be as small as 1 byte rather than 4 bytes in BSON. Short strings avoid an extra 4-byte length prefix. Array keys are encoded implicitly rather than explicitly, unlike BSON. Spark, Snowflake, DuckDB, and Iceberg have already adopted this encoding. By aligning with it, Velox can directly reuse upstream codec libraries and avoid introducing a private format.
Accordingly, Velox keeps its in-memory representation binary-compatible with the Parquet
Variantencoding:Variantencoding. This substantially reduces round-trip conversion cost and compatibility risk in the read/write path.metadataandvaluebyte streams into memory through zero-copy or minimal conversion, and later operators extract paths directly from those two columns.3.3 Why This Must Be a Built-in Type
TypeKind::VARIANTBaseVector::create()dispatches vector creation based onTypeKind. If it reusedTypeKind::ROW,create()would produce aRowVectorrather than aVariantVector. A dedicatedTypeKind::VARIANTensures that the factory constructs the correct vector type. The reasonVariantVectorcannot reuseRowVectoris described next.VariantVector: Why It Cannot Inherit fromRowVectorThe
RowVectorconstructor inComplexVector.himposes a hard constraint:children_.size() <= type->size()means that the number of children must be known at construction time and cannot exceed the number of fields declared byRowType. ForVariantVector:VariantTypeis intentionally schema-free, as described in Section 3.1, sotype->size()cannot reflect shredded columns.shreddedColumns_are attached dynamically by the reader from different Parquet row groups, and different batches may expose different path sets.VariantTypereturned a dynamicsize(), then twoVariantVectorinstances with different shredded columns would also have differentTypeinstances, even though execution should still treat them as the same logical type.Even if the construction constraint were bypassed,
RowVector::copy()would still assume that source and target children correspond one by one by index, as shown inComplexVector.cpp:187:When two
VariantVectorinstances have different shredded schemas, for example, target has{event_type, event_ts}while source has{event_type, user_id}, the meaning ofchildren_[2]is already different. A positional, column-by-columncopy()is therefore semantically incorrect.Note
VariantVector::copy()requires its own merge logic. When source and target shredded schemas differ, it must align paths by union, arbitrate types through compatible promotion, fall back to binary form when types are incompatible, and then append column by column. When the schemas match, it can take a fast path and copy column by column directly with overhead comparable toRowVector::copy().Comparison: why
IPPrefixTypecan inherit fromRowVectorIPPrefixTypeVariantROW(HUGEINT, TINYINT)known at compile timemetadata + value +runtime-dynamic shredded columnstype->size()= 2copy()semanticsSELECT data.ip, data.prefix FROM t, user reads fields directly by nameSELECT variant_get(data, '$.event_type', 'string') FROM t, user accesses through functionsAlternative considered: In the proof-of-concept stage,
VariantVectorinherited fromRowVectorand reusedVectorEncoding::ROW. That approach reduced the amount of override work, but it introduced two problems.RowVector::copy()cannot handle mismatched shredded schemas. In addition,is_row_kind()would returntrue, causingVARIANTto enterROWcode paths incorrectly and leading to invalid type conversions. The final design therefore makesVariantVectorinherit directly fromBaseVector.VectorEncoding::VARIANTOnce
VariantVectorinherits fromBaseVector, it also needs its own encoding for two reasons:Reusing
ROWencoding would cause undefined behavior. The codebase contains many paths that docase VectorEncoding::Simple::ROW:and then cast directly toRowVector*. For example, inFieldReference.cpp:If
VariantVectorreusedVectorEncoding::ROW, those paths would cast it toRowVector*, which is undefined behavior.Dedicated dispatch path:
VectorEncoding::VARIANTallows allBaseVectorvirtual functions such asslice,copy,estimateFlatSize, serialization, and hashing to take dedicatedVariant-specific branches rather than patching those paths withisVariant()checks.4. Implementation Plan
Note
When submitting PRs, keep each PR as small and self-contained as possible so reviewers can reason about it quickly. The phases below are logical groupings. A single phase may still be split into multiple PRs.
Phase 1: Foundational Type and Vector Support (3 PRs)
TypeKind+VariantType(minimum compilable change): add theTypeKind::VARIANTenum,TypeTraits, and theVariantTypeclass; add aVARIANTbranch toVELOX_DYNAMIC_TYPE_DISPATCH_IMPLwithout falling through toROW; and fix globalswitch(typeKind)compilation issues.VariantVector+BaseVectordispatch: addVectorEncoding::VARIANT, theVariantVectorclass inheriting fromBaseVector, factory creation, aDecodedVectorbranch, andVELOX_NYIstubs for all serialization, row container, and bridge paths. This PR must land together with theBaseVectorinheritance change to avoid falling through toROWcode paths and triggering undefined behavior.variant_get: first try the fast path by queryingshreddedColumn(path), and fall back to decoding the binary representation inmetadata + value.Later Phases
VariantColumnReaderto readmetadata + valueand attach shredded columns.VARIANTasmetadata + value + shreddedColumns. The shredded columns are part of the value and must participate in serialization.VariantSQL functions from Spark 4.1 and wire up Substrait type mapping.5. Risks and Known Limitations
VARIANTcannot participate inGROUP BY, join keys,ORDER BY,DISTINCT, and similar operations. This is a Spark 4.1 semantic restriction, not a Velox-specific limitation.TypeKindandVectorEncodingaffects roughly 20 encoding switches and even moretypeKindswitches, creating a long maintenance tail.copy()andcopyRanges()are particularly complex because they include merge logic.5.1 Serialization Boundary Strategy: Preserve Shredded State
At Exchange and Spill boundaries, this design preserves shredded state instead of reconstructing a complete
metadata + valueblob each time:VARBINARYlayoutO(variant_size * shredded_fields)per rowThe rationale is straightforward. The entire purpose of Shredding is to avoid binary decoding. If every exchange or spill reconstructs a full blob, the fast path enabled by shredded columns survives only within a single pipeline. That outcome discards most of the performance benefit.
Version compatibility: In Phase 1, all serialization paths throw
VELOX_NYIforVARIANT. In Phase 2, the design adopts a versioned wire format. Receivers that do not support shredded serialization automatically fall back to full-blob form.6. Appendix: Understanding
VARIANTBinary Encoding Throughvariant_getThis section walks through a concrete example of the full execution path of
variant_getand explains the internal layout of the ParquetVariantbinary encoding.6.1 Example Data
{ "owner": "Alice", "contacts": [ { "age": 30, "name": "Bob", "phones": [ {"number": "+1-555-1000", "type": "home"} ] } ] }Query:
variant_get(data, '$.contacts[0].phones[0].number', 'string')6.2 Binary Layout of
VARIANTA
Variantvalue consists of two parts: Metadata, the per-row field-name dictionary, and Value, the per-row recursive binary tree with a header byte. Object nodes in Value refer to the Metadata dictionary throughfield_id. Fields are stored in dictionary order to support binary search, and the offset array enablesO(1)jumps to any child node.The diagram below shows the complete binary layout of the example data. The red arrows show the extraction path for
$.contacts[0].phones[0].number:The full path costs 3 dictionary binary searches, 3 object header parses, 2 array header parses, and 5 offset reads, for a total of about 16 ops/row. Each operation is simple pointer arithmetic that reads a 1-byte to 4-byte integer. The process requires no memory allocation and no string copying.
All reactions