Skip to content

Commit 788a18a

Browse files
authored
Merge branch 'release/26.08' into cudf-spark-rename-1
2 parents 1bc4c80 + b9ca947 commit 788a18a

6 files changed

Lines changed: 516 additions & 90 deletions

File tree

integration_tests/src/main/python/parquet_test.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from parquet_write_test import parquet_datetime_gen_simple, parquet_nested_datetime_gen, parquet_ts_write_options
2323
from marks import *
2424
import pyarrow as pa
25+
import pyarrow.parquet as pq
2526
from parquet_test_utils import parquet_row_group_midpoints
2627
from pyspark.sql.types import *
2728
from pyspark.sql.functions import *
@@ -1991,3 +1992,185 @@ def setup_table(spark):
19911992
with_cpu_session(lambda spark: setup_table(spark))
19921993
assert_gpu_and_cpu_are_equal_collect(lambda spark: spark.read.parquet(data_path).select("p"),
19931994
conf={"spark.rapids.sql.columnSizeBytes": "100"})
1995+
1996+
1997+
def _write_parquet_unknown_null_table(
1998+
data_path, with_list=False, with_map=False, field_id=None):
1999+
"""Write INT32 physical + UNKNOWN/Null logical annotation (Spark void_in_parquet shape)."""
2000+
if with_list:
2001+
table = pa.table({
2002+
'list_void': pa.array([[None, None], [None], None], type=pa.list_(pa.null())),
2003+
})
2004+
elif with_map:
2005+
table = pa.table({
2006+
'map_void': pa.array(
2007+
[{1: None, 2: None}, {3: None}, None],
2008+
type=pa.map_(pa.int32(), pa.null())),
2009+
})
2010+
elif field_id is not None:
2011+
arrow_schema = pa.schema([
2012+
pa.field('void_col', pa.null(), metadata={b'PARQUET:field_id': str(field_id).encode()}),
2013+
])
2014+
table = pa.Table.from_arrays(
2015+
[pa.array([None, None, None], type=pa.null())], schema=arrow_schema)
2016+
else:
2017+
table = pa.table({
2018+
'id': pa.array([1, 2, 3], type=pa.int32()),
2019+
'void_col': pa.array([None, None, None], type=pa.null()),
2020+
})
2021+
pq.write_table(table, data_path)
2022+
2023+
2024+
# SPARK-56045 / SPARK-54220: Parquet UNKNOWN logical type annotation. PyArrow null columns are
2025+
# written as INT32 physical + UNKNOWN/Null logical annotation.
2026+
2027+
@pytest.mark.skipif(is_spark_411_or_later(),
2028+
reason='pre-SPARK-54220 physical-type behavior')
2029+
@pytest.mark.parametrize('reader_confs', reader_opt_confs)
2030+
def test_parquet_unknown_type_annotation_pre_411_physical(spark_tmp_path, reader_confs):
2031+
data_path = spark_tmp_path + '/PARQUET_UNKNOWN_PRE_411'
2032+
_write_parquet_unknown_null_table(data_path)
2033+
2034+
def read_and_check_schema(spark):
2035+
df = spark.read.parquet(data_path)
2036+
assert df.schema['void_col'].dataType == IntegerType(), \
2037+
f"expected void_col=IntegerType, got {df.schema['void_col'].dataType}"
2038+
return df
2039+
2040+
assert_gpu_and_cpu_are_equal_collect(read_and_check_schema, conf=reader_confs)
2041+
2042+
2043+
@pytest.mark.skipif(not is_spark_412_or_later(),
2044+
reason='SPARK-56045 requires Spark 4.1.2+')
2045+
@pytest.mark.parametrize('reader_confs', reader_opt_confs)
2046+
def test_parquet_unknown_type_annotation_default_physical(spark_tmp_path, reader_confs):
2047+
data_path = spark_tmp_path + '/PARQUET_UNKNOWN'
2048+
_write_parquet_unknown_null_table(data_path)
2049+
2050+
conf = copy_and_update(reader_confs, {
2051+
'spark.sql.parquet.reader.respectUnknownTypeAnnotation.enabled': 'false',
2052+
})
2053+
2054+
def read_and_check_schema(spark):
2055+
df = spark.read.parquet(data_path)
2056+
assert df.schema['void_col'].dataType == IntegerType(), \
2057+
f"expected void_col=IntegerType, got {df.schema['void_col'].dataType}"
2058+
return df
2059+
2060+
assert_gpu_and_cpu_are_equal_collect(read_and_check_schema, conf=conf)
2061+
2062+
2063+
@pytest.mark.skipif(not is_spark_411_or_later(),
2064+
reason='SPARK-54220 requires Spark 4.1.1+')
2065+
@pytest.mark.parametrize('reader_confs', reader_opt_confs)
2066+
@allow_non_gpu('FileSourceScanExec', 'ColumnarToRowExec')
2067+
def test_parquet_unknown_type_annotation_respect_nulltype(spark_tmp_path, reader_confs):
2068+
data_path = spark_tmp_path + '/PARQUET_UNKNOWN'
2069+
_write_parquet_unknown_null_table(data_path)
2070+
2071+
# Spark 4.1.1 always maps UNKNOWN to NullType; 4.1.2+ needs the conf enabled.
2072+
conf = reader_confs
2073+
if is_spark_412_or_later():
2074+
conf = copy_and_update(reader_confs, {
2075+
'spark.sql.parquet.reader.respectUnknownTypeAnnotation.enabled': 'true',
2076+
})
2077+
2078+
def read_and_check_schema(spark):
2079+
df = spark.read.parquet(data_path)
2080+
assert df.schema['void_col'].dataType == NullType(), \
2081+
f"expected void_col=NullType, got {df.schema['void_col'].dataType}"
2082+
return df
2083+
2084+
# GPU Parquet scan does not support NullType yet; expect CPU fallback.
2085+
assert_gpu_fallback_collect(read_and_check_schema, 'FileSourceScanExec', conf=conf)
2086+
2087+
2088+
@pytest.mark.skipif(not is_spark_412_or_later(),
2089+
reason='SPARK-56045 requires Spark 4.1.2+')
2090+
@pytest.mark.parametrize('reader_confs', reader_opt_confs)
2091+
def test_parquet_unknown_type_annotation_explicit_int_schema(spark_tmp_path, reader_confs):
2092+
"""Explicit non-Null schema should strip UNKNOWN even when respect conf is true."""
2093+
data_path = spark_tmp_path + '/PARQUET_UNKNOWN_EXPLICIT'
2094+
_write_parquet_unknown_null_table(data_path)
2095+
2096+
read_schema = StructType([
2097+
StructField('id', IntegerType(), True),
2098+
StructField('void_col', IntegerType(), True),
2099+
])
2100+
conf = copy_and_update(reader_confs, {
2101+
'spark.sql.parquet.reader.respectUnknownTypeAnnotation.enabled': 'true',
2102+
})
2103+
2104+
def read_and_check_schema(spark):
2105+
df = spark.read.schema(read_schema).parquet(data_path)
2106+
assert df.schema['void_col'].dataType == IntegerType(), \
2107+
f"expected void_col=IntegerType, got {df.schema['void_col'].dataType}"
2108+
return df
2109+
2110+
assert_gpu_and_cpu_are_equal_collect(read_and_check_schema, conf=conf)
2111+
2112+
2113+
@pytest.mark.skipif(not is_spark_412_or_later(),
2114+
reason='SPARK-56045 requires Spark 4.1.2+')
2115+
@pytest.mark.parametrize('reader_confs', reader_opt_confs)
2116+
def test_parquet_unknown_type_annotation_preserves_field_id(spark_tmp_path, reader_confs):
2117+
data_path = spark_tmp_path + '/PARQUET_UNKNOWN_FIELD_ID'
2118+
_write_parquet_unknown_null_table(data_path, field_id=7)
2119+
2120+
read_schema = StructType([
2121+
StructField('renamed_void', IntegerType(), True, metadata=with_id(7)),
2122+
])
2123+
conf = copy_and_update(
2124+
reader_confs,
2125+
enable_parquet_field_id_read,
2126+
{'spark.sql.parquet.reader.respectUnknownTypeAnnotation.enabled': 'false'})
2127+
2128+
def read_and_check_schema(spark):
2129+
df = spark.read.schema(read_schema).parquet(data_path)
2130+
assert df.schema['renamed_void'].dataType == IntegerType(), \
2131+
f"expected renamed_void=IntegerType, got {df.schema['renamed_void'].dataType}"
2132+
return df
2133+
2134+
assert_gpu_and_cpu_are_equal_collect(read_and_check_schema, conf=conf)
2135+
2136+
2137+
@pytest.mark.skipif(not is_spark_412_or_later(),
2138+
reason='SPARK-56045 requires Spark 4.1.2+')
2139+
@pytest.mark.parametrize('reader_confs', reader_opt_confs)
2140+
def test_parquet_unknown_type_annotation_list_physical(spark_tmp_path, reader_confs):
2141+
"""Primitive-element lists bypass structural clipping; UNKNOWN must still be stripped."""
2142+
data_path = spark_tmp_path + '/PARQUET_UNKNOWN_LIST'
2143+
_write_parquet_unknown_null_table(data_path, with_list=True)
2144+
2145+
conf = copy_and_update(reader_confs, {
2146+
'spark.sql.parquet.reader.respectUnknownTypeAnnotation.enabled': 'false',
2147+
})
2148+
2149+
def read_and_check_schema(spark):
2150+
df = spark.read.parquet(data_path)
2151+
assert df.schema['list_void'].dataType == ArrayType(IntegerType()), \
2152+
f"expected ArrayType(IntegerType), got {df.schema['list_void'].dataType}"
2153+
return df
2154+
2155+
assert_gpu_and_cpu_are_equal_collect(read_and_check_schema, conf=conf)
2156+
2157+
2158+
@pytest.mark.skipif(not is_spark_412_or_later(),
2159+
reason='SPARK-56045 requires Spark 4.1.2+')
2160+
@pytest.mark.parametrize('reader_confs', reader_opt_confs)
2161+
def test_parquet_unknown_type_annotation_map_physical(spark_tmp_path, reader_confs):
2162+
"""Primitive-value maps bypass structural clipping; UNKNOWN must still be stripped."""
2163+
data_path = spark_tmp_path + '/PARQUET_UNKNOWN_MAP'
2164+
_write_parquet_unknown_null_table(data_path, with_map=True)
2165+
2166+
conf = copy_and_update(reader_confs, {
2167+
'spark.sql.parquet.reader.respectUnknownTypeAnnotation.enabled': 'false',
2168+
})
2169+
2170+
def read_and_check_schema(spark):
2171+
df = spark.read.parquet(data_path)
2172+
assert df.schema['map_void'].dataType == MapType(IntegerType(), IntegerType()), \
2173+
f"expected MapType(IntegerType, IntegerType), got {df.schema['map_void'].dataType}"
2174+
return df
2175+
2176+
assert_gpu_and_cpu_are_equal_collect(read_and_check_schema, conf=conf)

sql-plugin/src/main/scala/com/nvidia/spark/rapids/parquet/ParquetSchemaUtils.scala

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import ai.rapids.cudf.{ColumnView, DType, Table}
2424
import com.nvidia.spark.rapids.{CastOptions, GpuCast, GpuColumnVector, SchemaUtils}
2525
import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource}
2626
import com.nvidia.spark.rapids.shims.parquet.ParquetSchemaClipShims
27+
import com.nvidia.spark.rapids.shims.parquet.ParquetUnknownTypeAnnotationShims
2728
import org.apache.parquet.schema._
29+
import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
2830
import org.apache.parquet.schema.Type.Repetition
2931

3032
import org.apache.spark.sql.rapids.execution.TrampolineUtil
@@ -80,18 +82,93 @@ object ParquetSchemaUtils {
8082
clipParquetGroup(parquetType.asGroupType(), t, caseSensitive, useFieldId)
8183

8284
case _ =>
83-
// UDTs and primitive types are not clipped. For UDTs, a clipped version might not be able
84-
// to be mapped to desired user-space types. So UDTs shouldn't participate schema merging.
85-
parquetType
85+
// UDTs, primitive types, and primitive-element arrays/maps are not clipped
86+
// structurally. Still normalize UNKNOWN annotations so cuDF sees the physical
87+
// type when Spark would ignore the annotation (SPARK-56045).
88+
stripIgnoredUnknownAnnotation(parquetType, catalystType)
8689
}
8790

88-
if (useFieldId && parquetType.getId != null) {
91+
// Nested rebuilds may already carry field IDs; only re-apply when missing.
92+
if (useFieldId && parquetType.getId != null && newParquetType.getId == null) {
8993
newParquetType.withId(parquetType.getId.intValue())
9094
} else {
9195
newParquetType
9296
}
9397
}
9498

99+
/**
100+
* Leaf Catalyst type used when deciding whether an UNKNOWN annotation should be
101+
* stripped for nested array/map groups that bypass structural clipping.
102+
*/
103+
private def unknownAnnotationLeafType(catalystType: DataType): DataType = {
104+
catalystType match {
105+
case ArrayType(elementType, _) => unknownAnnotationLeafType(elementType)
106+
case MapType(_, valueType, _) => unknownAnnotationLeafType(valueType)
107+
case other => other
108+
}
109+
}
110+
111+
/**
112+
* Rebuild primitives without an UNKNOWN logical annotation when Spark would not map
113+
* that annotation to NullType for the requested Catalyst type. Recurses through group
114+
* nodes so primitive-element lists/maps that skip structural clipping are covered.
115+
*
116+
* @param preserveFieldId when rebuilding nested primitives (not returning through
117+
* [[clipParquetType]]), copy the original field ID onto the rebuilt type.
118+
*/
119+
@scala.annotation.nowarn("msg=method as in class Builder is deprecated")
120+
private def stripIgnoredUnknownAnnotation(
121+
parquetType: Type,
122+
catalystType: DataType,
123+
preserveFieldId: Boolean = false): Type = {
124+
if (!parquetType.isPrimitive) {
125+
val group = parquetType.asGroupType()
126+
val leafType = unknownAnnotationLeafType(catalystType)
127+
val fields = group.getFields.asScala
128+
val strippedFields = fields.map { field =>
129+
stripIgnoredUnknownAnnotation(field, leafType, preserveFieldId = true)
130+
}
131+
if (fields.iterator.zip(strippedFields.iterator).forall {
132+
case (original, stripped) => original eq stripped
133+
}) {
134+
parquetType
135+
} else {
136+
group.withNewFields(strippedFields.asJava)
137+
}
138+
} else {
139+
val primitive = parquetType.asPrimitiveType()
140+
val rawAnnotation = primitive.getLogicalTypeAnnotation
141+
val leafType = unknownAnnotationLeafType(catalystType)
142+
// Respect conf for inferred/requested NullType; strip UNKNOWN when the requested
143+
// type is a non-Null physical type even if respectUnknownTypeAnnotation is true.
144+
val effectiveAnnotation =
145+
if (leafType != NullType &&
146+
ParquetUnknownTypeAnnotationShims.mapsToNullType(rawAnnotation)) {
147+
null
148+
} else {
149+
ParquetUnknownTypeAnnotationShims.effectiveLogicalTypeAnnotation(rawAnnotation)
150+
}
151+
if (rawAnnotation == effectiveAnnotation) {
152+
parquetType
153+
} else {
154+
val builder = Types.primitive(primitive.getPrimitiveTypeName, primitive.getRepetition)
155+
if (primitive.getPrimitiveTypeName == PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) {
156+
builder.length(primitive.getTypeLength)
157+
}
158+
if (effectiveAnnotation != null) {
159+
builder.as(effectiveAnnotation)
160+
}
161+
val rebuilt = builder.named(primitive.getName)
162+
if (preserveFieldId && primitive.getId != null) {
163+
rebuilt.withId(primitive.getId.intValue())
164+
} else {
165+
// Top-level field IDs are re-applied by clipParquetType after this returns.
166+
rebuilt
167+
}
168+
}
169+
}
170+
}
171+
95172
/**
96173
* Whether a Catalyst DataType is primitive. Primitive DataType is not equivalent to
97174
* AtomicType. For example, CalendarIntervalType is primitive, but it's not an

0 commit comments

Comments
 (0)