This is an automated email from the ASF dual-hosted git repository.
marin-ma pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new 6fb23c7d3d [VL] Prune map entries at the Parquet scan to the keys a
query reads (#13109)
6fb23c7d3d is described below
commit 6fb23c7d3d64e0b9e84fd53472b3aafa04da6621
Author: Rong Ma <[email protected]>
AuthorDate: Fri Oct 9 17:24:16 2026 +0100
[VL] Prune map entries at the Parquet scan to the keys a query reads
(#13109)
---
.../apache/gluten/execution/VeloxDeltaSuite.scala | 87 ++-
.../gluten/proto/RequiredSubfieldsExtension.proto | 37 ++
.../gluten/backendsapi/velox/VeloxRuleApi.scala | 1 +
.../backendsapi/velox/VeloxSparkPlanExecApi.scala | 29 +-
.../backendsapi/velox/VeloxTransformerApi.scala | 33 +-
.../org/apache/gluten/config/VeloxConfig.scala | 14 +
.../gluten/extension/ScanMapKeyPruning.scala | 424 +++++++++++++
.../gluten/execution/ScanMapKeyPruningSuite.scala | 677 +++++++++++++++++++++
.../spark/sql/execution/GlutenHiveUDFSuite.scala | 36 +-
cpp/velox/CMakeLists.txt | 1 +
.../operators/functions/overlay/GetMapValue.cc | 40 ++
.../operators/functions/overlay/GetMapValue.h | 62 ++
.../functions/overlay/RegisterFunctionOverlay.cc | 6 +
cpp/velox/substrait/SubstraitToVeloxPlan.cc | 86 ++-
cpp/velox/tests/SparkFunctionTest.cc | 33 +
docs/velox-configuration.md | 1 +
.../gluten/execution/DeltaScanTransformer.scala | 18 +-
.../org/apache/gluten/execution/DeltaSuite.scala | 2 +-
.../gluten/backendsapi/SparkPlanExecApi.scala | 11 +
.../apache/gluten/backendsapi/TransformerApi.scala | 10 +-
.../execution/BasicScanExecTransformer.scala | 25 +-
.../execution/BatchScanExecTransformer.scala | 18 +-
.../execution/FileSourceScanExecTransformer.scala | 12 +-
.../org/apache/gluten/execution/SubfieldPath.scala | 51 ++
.../gluten/expression/ExpressionConverter.scala | 7 +
.../org/apache/spark/sql/GlutenQueryTest.scala | 4 +-
26 files changed, 1696 insertions(+), 29 deletions(-)
diff --git
a/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
b/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
index 851cf1dcbc..3ffee52b4b 100644
---
a/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
+++
b/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
@@ -18,7 +18,8 @@ package org.apache.gluten.execution
import org.apache.gluten.config.VeloxDeltaConfig
-import org.apache.spark.sql.Row
+import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.util.SparkVersionUtil
class VeloxDeltaSuite extends DeltaSuite {
test("delta: change data feed scan offload can be disabled") {
@@ -49,4 +50,88 @@ class VeloxDeltaSuite extends DeltaSuite {
}
}
}
+
+ Seq("name", "id").foreach {
+ mode =>
+ test(s"map-key pruning is disabled under column mapping mode = $mode") {
+ withTable("delta_cm_map") {
+ spark.sql(s"""
+ |create table delta_cm_map
+ | (id bigint, m map<string, struct<s string, t
bigint>>)
+ |using delta
+ |tblproperties ("delta.columnMapping.mode" = "$mode")
+ |""".stripMargin)
+ spark.sql(
+ "insert into delta_cm_map select id, " +
+ "map('a', named_struct('s', concat('v', cast(id % 7 as string)),
't', id), " +
+ "'b', named_struct('s', 'x', 't', id * 2)) from range(0, 500)")
+ val pruningFlag =
"spark.gluten.sql.columnar.backend.velox.scanMapKeyPruningEnabled"
+ withSQLConf(pruningFlag -> "true", "spark.sql.adaptive.enabled" ->
"false") {
+ runQueryAndCompare(
+ "select sum(m['a'].t), max(m['a'].s) from delta_cm_map where
m['a'].s = 'v3'") {
+ df =>
+ val subfields = getExecutedPlan(df)
+ .collect { case scan: DeltaScanTransformer =>
scan.requiredMapSubfields }
+ .flatten
+ assert(subfields.isEmpty, s"physical column names cannot be
pruned: $subfields")
+ }
+ }
+ }
+ }
+ }
+
+ testWithMinSparkVersion("map-key pruning on delta scan with and without
deletion vector", "3.4") {
+ withTempPath {
+ p =>
+ val path = p.getCanonicalPath
+ spark
+ .range(0, 2000)
+ .selectExpr(
+ "id",
+ "map('a', named_struct('s', concat('v', cast(id % 7 as string)),
't', id), " +
+ "'b', named_struct('s', 'x', 't', id * 2), " +
+ "'c', named_struct('s', 'y', 't', id + 1)) as m"
+ )
+ .coalesce(1)
+ .write
+ .format("delta")
+ .save(path)
+ val sql = s"SELECT count(*), sum(m['a'].t), max(m['a'].s) " +
+ s"FROM delta.`$path` WHERE m['a'].s = 'v3'"
+ val pruningFlag =
"spark.gluten.sql.columnar.backend.velox.scanMapKeyPruningEnabled"
+
+ def scanSubfields(df: DataFrame): Map[String, Seq[String]] =
+ getExecutedPlan(df)
+ .collect { case scan: DeltaScanTransformer =>
scan.requiredMapSubfields }
+ .flatten
+ .toMap
+ .map { case (col, paths) => col -> paths.map(_.toString) }
+
+ withSQLConf(pruningFlag -> "true", "spark.sql.adaptive.enabled" ->
"false") {
+ runQueryAndCompare(sql) {
+ df =>
+ val subfields = scanSubfields(df)
+ assert(subfields.contains("m"), s"expected pruning on m, got
$subfields")
+ assert(subfields("m").exists(_.contains("m[\"a\"]")),
subfields("m").toString)
+ assert(!subfields("m").exists(_.contains("[\"b\"]")),
subfields("m").toString)
+ }
+ }
+
+ spark.sql(
+ s"ALTER TABLE delta.`$path` SET TBLPROPERTIES
('delta.enableDeletionVectors' = true)")
+ spark.sql(s"DELETE FROM delta.`$path` WHERE id % 10 = 0")
+ if (SparkVersionUtil.gteSpark35) {
+ withSQLConf(pruningFlag -> "true", "spark.sql.adaptive.enabled" ->
"false") {
+ runQueryAndCompare(sql) {
+ df =>
+ assert(
+ df.queryExecution.executedPlan
+ .collect { case _: DeltaScanTransformer => true }
+ .nonEmpty)
+ assert(scanSubfields(df).contains("m"))
+ }
+ }
+ }
+ }
+ }
}
diff --git
a/backends-velox/src/main/resources/org/apache/gluten/proto/RequiredSubfieldsExtension.proto
b/backends-velox/src/main/resources/org/apache/gluten/proto/RequiredSubfieldsExtension.proto
new file mode 100644
index 0000000000..576385c79c
--- /dev/null
+++
b/backends-velox/src/main/resources/org/apache/gluten/proto/RequiredSubfieldsExtension.proto
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: Apache-2.0
+syntax = "proto3";
+
+package gluten;
+
+option java_package = "org.apache.gluten.proto";
+option java_multiple_files = true;
+
+// Subfields of scan output columns that the query accesses, carried in
+// ReadRel.advanced_extension.enhancement. The native reader treats a listed
+// column's set as complete: map entries under keys not listed are not
+// materialized and struct fields not listed are null. Columns without an entry
+// are read whole.
+message RequiredSubfieldsExtension {
+ // One step below a column: a struct field, or a map lookup by key.
+ message PathElement {
+ oneof element {
+ string field = 1;
+ string string_key = 2;
+ int64 long_key = 3;
+ }
+ }
+
+ // A path under a column, e.g. elements [string_key "a", field "t"] for
m["a"].t.
+ message Subfield {
+ repeated PathElement elements = 1;
+ }
+
+ message ColumnSubfields {
+ // Column and field names as the scan names them in its schema
(case-folded by the driver when
+ // case-insensitive).
+ string column = 1;
+ repeated Subfield subfields = 2;
+ }
+
+ repeated ColumnSubfields columns = 1;
+}
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
index 0937d5d065..63c8880e8a 100644
---
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
+++
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
@@ -126,6 +126,7 @@ object VeloxRuleApi {
injector.injectPostTransform(_ => PartialFallbackRules())
injector.injectPostTransform(_ => RemoveNativeWriteFilesSortAndProject())
injector.injectPostTransform(_ => PushDownFilterToScan)
+ injector.injectPostTransform(_ => ScanMapKeyPruning)
injector.injectPostTransform(_ => PushDownInputFileExpression.PostOffload)
injector.injectPostTransform(_ => EnsureLocalSortRequirements)
injector.injectPostTransform(_ => EliminateLocalSort)
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
index 90dfc0b260..a7fd67ea81 100644
---
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
+++
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
@@ -1123,10 +1123,31 @@ class VeloxSparkPlanExecApi extends SparkPlanExecApi
with Logging {
left: ExpressionTransformer,
right: ExpressionTransformer,
original: GetMapValue): ExpressionTransformer = {
- GenericExpressionTransformer(
- ExpressionMappings.expressionsMap(classOf[ElementAt]),
- Seq(left, right),
- original)
+ // Emitted as get_map_value (Gluten function overlay) rather than rewritten
+ // to element_at: the map-only subscript is subfield-pushdown capable, so a
+ // scan remaining filter on m['k'] extracts m["k"] and keeps map-key
+ // pruning effective.
+ GenericExpressionTransformer(substraitExprName, Seq(left, right), original)
+ }
+
+ /**
+ * ElementAt on a map is emitted as get_map_value, like GetMapValue, so that
a scan remaining
+ * filter such as element_at(m, 'k').x = 1 extracts the subfield m["k"] and
map-key pruning stays
+ * effective; Velox's element_at also serves arrays and is not
subfield-pushdown capable, so it
+ * would add the bare column m and keep every entry. The two agree on maps:
the value under the
+ * key, or NULL when it is absent. In ANSI mode a missing key is an error
instead, and the mapped
+ * element_at is kept.
+ */
+ override def genElementAtTransformer(
+ substraitExprName: String,
+ left: ExpressionTransformer,
+ right: ExpressionTransformer,
+ original: ElementAt): ExpressionTransformer = {
+ if (original.left.dataType.isInstanceOf[MapType] && !original.failOnError)
{
+ GenericExpressionTransformer(ExpressionNames.GET_MAP_VALUE, Seq(left,
right), original)
+ } else {
+ GenericExpressionTransformer(substraitExprName, Seq(left, right),
original)
+ }
}
override def genStringToMapTransformer(
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxTransformerApi.scala
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxTransformerApi.scala
index 04df524427..5fabab93a6 100644
---
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxTransformerApi.scala
+++
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxTransformerApi.scala
@@ -18,10 +18,10 @@ package org.apache.gluten.backendsapi.velox
import org.apache.gluten.backendsapi.{BackendsApiManager, TransformerApi}
import org.apache.gluten.exception.GlutenException
-import org.apache.gluten.execution.WriteFilesExecTransformer
+import org.apache.gluten.execution.{SubfieldElement, SubfieldPath,
WriteFilesExecTransformer}
import org.apache.gluten.execution.datasource.GlutenFormatFactory
import org.apache.gluten.expression.ConverterUtils
-import org.apache.gluten.proto.{ConfigMap, IcebergReadExtension}
+import org.apache.gluten.proto.{ConfigMap, IcebergReadExtension,
RequiredSubfieldsExtension}
import org.apache.gluten.runtime.Runtimes
import org.apache.gluten.substrait.SubstraitContext
import org.apache.gluten.substrait.expression.{ExpressionBuilder,
ExpressionNode}
@@ -152,6 +152,35 @@ class VeloxTransformerApi extends TransformerApi with
Logging {
packPBMessage(extensionBuilder.build())
}
+ override def packRequiredSubfields(subfields: Map[String,
Seq[SubfieldPath]]): Any = {
+ val extension = RequiredSubfieldsExtension.newBuilder()
+ subfields.toSeq.sortBy(_._1).foreach {
+ case (column, paths) =>
+ val columnBuilder =
+
RequiredSubfieldsExtension.ColumnSubfields.newBuilder().setColumn(column)
+ paths.foreach {
+ path =>
+ require(
+ path.column == column,
+ s"Subfield path $path declared under column $column")
+ val subfield = RequiredSubfieldsExtension.Subfield.newBuilder()
+ path.elements.foreach {
+ element =>
+ val pe = RequiredSubfieldsExtension.PathElement.newBuilder()
+ element match {
+ case SubfieldElement.Field(name) => pe.setField(name)
+ case SubfieldElement.StringKey(key) => pe.setStringKey(key)
+ case SubfieldElement.LongKey(key) => pe.setLongKey(key)
+ }
+ subfield.addElements(pe)
+ }
+ columnBuilder.addSubfields(subfield)
+ }
+ extension.addColumns(columnBuilder)
+ }
+ packPBMessage(extension.build())
+ }
+
override def invalidateSQLExecutionResource(executionId: String): Unit = {
GlutenDriverEndpoint.invalidateResourceRelation(executionId)
}
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
index ed2217f252..6d66080a23 100644
--- a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
+++ b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
@@ -82,6 +82,9 @@ class VeloxConfig(conf: SQLConf) extends GlutenConfig(conf) {
def veloxOrcScanEnabled: Boolean =
getConf(VELOX_ORC_SCAN_ENABLED)
+ def scanMapKeyPruningEnabled: Boolean =
+ getConf(SCAN_MAP_KEY_PRUNING_ENABLED)
+
def floatingPointMode: String = getConf(FLOATING_POINT_MODE)
def enableRewriteCastArrayToString: Boolean =
@@ -279,6 +282,17 @@ object VeloxConfig extends ConfigRegistry {
.intConf
.createWithDefault(2)
+ val SCAN_MAP_KEY_PRUNING_ENABLED =
+
buildConf("spark.gluten.sql.columnar.backend.velox.scanMapKeyPruningEnabled")
+ .doc(
+ "When true, a map column read by a native Parquet scan is declared to
Velox with only " +
+ "the constant keys the query looks up (m['k'], element_at(m, 'k'),
null checks), so " +
+ "the reader skips the other entries. Applies when every use of the
map sits in the " +
+ "filters and projections directly above the scan and the map is
dropped before any " +
+ "other operator sees it; otherwise the map is read whole. See
ScanMapKeyPruning.")
+ .booleanConf
+ .createWithDefault(false)
+
val COLUMNAR_VELOX_GLOG_VERBOSE_LEVEL =
buildConf("spark.gluten.sql.columnar.backend.velox.glogVerboseLevel")
.internal()
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/extension/ScanMapKeyPruning.scala
b/backends-velox/src/main/scala/org/apache/gluten/extension/ScanMapKeyPruning.scala
new file mode 100644
index 0000000000..14f8d0a20b
--- /dev/null
+++
b/backends-velox/src/main/scala/org/apache/gluten/extension/ScanMapKeyPruning.scala
@@ -0,0 +1,424 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.gluten.extension
+
+import org.apache.gluten.config.VeloxConfig
+import org.apache.gluten.execution.{BasicScanExecTransformer,
FilterExecTransformerBase, GenerateExecTransformer, ProjectExecTransformerBase,
SubfieldElement, SubfieldPath}
+import org.apache.gluten.execution.SubfieldElement.{Field, LongKey, StringKey
=> StrKey}
+import org.apache.gluten.expression.ConverterUtils
+import org.apache.gluten.substrait.rel.LocalFilesNode.ReadFileFormat
+
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.{FilterExec, GenerateExec, ProjectExec,
SparkPlan}
+import org.apache.spark.sql.types._
+import org.apache.spark.unsafe.types.UTF8String
+
+import java.nio.charset.StandardCharsets
+
+import scala.collection.mutable
+import scala.util.control.NonFatal
+
+/**
+ * Physical plan rule that derives Velox required subfields for map columns
read by a native Parquet
+ * scan, so that the reader prunes map entries the query never accesses.
+ *
+ * Background. Parquet stores a map as a repeated `key_value` group: one key
column chunk and one
+ * value column chunk per row group, covering every entry of every row.
Evaluating `m['k1']` on the
+ * scan output therefore decodes all entries into a MapVector, although only
one key is needed.
+ * Velox's Hive connector lets a `HiveColumnHandle` declare required
subfields, written in Velox
+ * `Subfield` syntax such as `m["k1"].f1`; `makeScanSpec` turns the map keys
they name into a key
+ * filter on the map's key stream, so the SelectiveMapColumnReader
materializes only matching
+ * entries. Struct fields not covered by any required subfield are filled with
nulls.
+ *
+ * Velox treats the declared set as complete and returns null for anything
outside it, so the
+ * declared paths must cover every read of the column. The rule therefore only
handles one plan
+ * shape, in which that is easy to show:
+ * {{{
+ * Scan [id, m] filters: m["a"].s = 'v3'
+ * -> Filter* more predicates over keyed paths
+ * -> Project* keyed paths under aliases; m
may be forwarded
+ * -> Generate* m forwarded only if still
needed above
+ * -> a Project (or Generate) that no longer outputs m end of the chain
+ * }}}
+ * Once a Project drops the attribute (and every alias that still carries map
data of it), no
+ * operator above can reference it: in a resolved plan an attribute can only
be referenced if some
+ * child outputs it. Whatever sits above the chain is therefore irrelevant and
needs no analysis.
+ * Aliases of keyed paths that leave the chain (`m['a'].t AS x`) are fine as
well: their value
+ * depends only on entries the declared paths keep.
+ *
+ * Supported, that is, pruned:
+ * - filters on constant-key lookups, `m['a'].s = 'v3'`, `m[1].t > 0`,
`element_at(m, 'a')`,
+ * including the `isnotnull(m)` Spark infers next to them. Such a filter
reaches Velox as the
+ * scan's remaining filter, whose subfields Velox adds to the declaration;
the Velox backend
+ * emits both lookups as the pushdown-capable `get_map_value`, so they
contribute `m["a"]`
+ * rather than the bare column (see
VeloxSparkPlanExecApi.genElementAtTransformer);
+ * - keyed values projected or aggregated, `SELECT m['a'].t`,
`sum(m['a'].t)`, also under a
+ * `count(*)`, a `LIMIT`, an `ORDER BY ... LIMIT`, a `UNION ALL` of such
projections, a write of
+ * such values, a UDF or script transformation applied to them, or a join
whose sides only read
+ * keyed values below the shuffle: in all of these Spark or Gluten places
the keyed access in a
+ * Project that drops the map before anything else sees it;
+ * - maps nested in structs, `c.m['a']`, including the `c.m AS _extract_m`
alias Spark's
+ * NestedColumnAliasing inserts; sibling struct fields are declared
alongside;
+ * - generators over a keyed value, `explode(m['a'])`: Gluten's
pre-projection computes the lookup
+ * below the Generate, and the Generate forwards the map only while an
operator above still
+ * needs it, so the chain may end at the Generate itself;
+ * - window partition keys, `OVER (PARTITION BY m['a'])`, which Spark's
analyzer extracts into a
+ * Project below the Window's shuffle;
+ * - null checks on keyed paths, `m['a'] IS NULL`.
+ *
+ * Not supported, that is, the map is read whole:
+ * - the map itself, or an alias still holding it, reaching an exchange, a
join, an aggregate, a
+ * union, a write, a UDTF, the fragment's output or any other operator
that is not a chain node;
+ * this includes `ORDER BY m['a']`, whose pre-projection must forward the
map through the
+ * shuffle because the Sort still outputs it;
+ * - whole-map uses anywhere on the chain, `size(m)`, `map_keys(m)`,
`explode(m)`, `m[key_col]`;
+ * - key types without a Velox subscript form, such as date or decimal, and
string keys whose
+ * bytes are not valid UTF-8.
+ *
+ * A chain ends at the first operator that is not a chain node, an exchange
included, so a scan's
+ * declaration depends only on the operators between it and that point.
Structurally identical
+ * exchange subtrees therefore always receive identical declarations, and
exchange reuse is
+ * unaffected.
+ *
+ * Example. For
+ * {{{
+ * SELECT sum(m['a'].t) FROM t WHERE m['a'].s = 'v3'
+ * }}}
+ * Spark places `m['a'].t AS _extract_t` in a Project below the aggregate, so
the chain is Scan ->
+ * Filter -> Project and the declared paths are `m["a"].s` and `m["a"].t`
(shown in Velox Subfield
+ * syntax, which SubfieldPath.toString renders; the wire format is structural,
one element per step,
+ * see RequiredSubfieldsExtension.proto).
+ */
+object ScanMapKeyPruning extends Rule[SparkPlan] {
+
+ // One element of a subfield path below the attribute, mirroring Velox
Subfield::PathElement:
+ // NestedField, StringSubscript, LongSubscript. `c.m["k1"].f1` is attribute
`c` followed by
+ // Field("m"), StrKey("k1"), Field("f1").
+ private type Elem = SubfieldElement
+
+ /** What the chain collectDeclarations collects for one scan attribute. */
+ private class CollectedPaths {
+ // Paths whose values are read; the map entries they name must be
materialized.
+ val valuePaths: mutable.LinkedHashSet[List[Elem]] =
+ mutable.LinkedHashSet[List[Elem]]()
+ // Paths referenced only by IsNull / IsNotNull; a null check reads the
null bitmap, which the
+ // reader produces for anything it materializes, so it needs no map
entries of its own.
+ val nullCheckPaths: mutable.LinkedHashSet[List[Elem]] =
+ mutable.LinkedHashSet[List[Elem]]()
+ // Set when some reference cannot be declared; the attribute is then left
alone.
+ var abandoned: Boolean = false
+ }
+
+ override def apply(plan: SparkPlan): SparkPlan = {
+ if (!VeloxConfig.get.scanMapKeyPruningEnabled) {
+ return plan
+ }
+ // Walk down from the root, remembering the chain nodes directly above the
current node,
+ // nearest first. Any other operator restarts the chain. Declarations are
kept per scan object,
+ // since two scans of one table are distinct objects.
+ val declarationsByScan =
+ new java.util.IdentityHashMap[SparkPlan, Map[String,
Seq[SubfieldPath]]]()
+ def collectDeclarations(node: SparkPlan, chain: List[SparkPlan]): Unit =
node match {
+ case scan: BasicScanExecTransformer if isPrunableParquetScan(scan) =>
+ val mapAttrs = scan.output.filter(a => containsMapType(a.dataType) &&
a.name.nonEmpty)
+ if (mapAttrs.nonEmpty) {
+ val filters = scan.filterExprs()
+ val subfields = mapAttrs.flatMap {
+ attr =>
+ pathsToDeclare(filters, chain, attr).map(paths =>
renderSubfieldPaths(attr, paths))
+ }.toMap
+ if (subfields.nonEmpty) {
+ declarationsByScan.put(scan, subfields)
+ }
+ }
+ case _ =>
+ val next = if (isChainOperator(node)) node :: chain else Nil
+ node.children.foreach(collectDeclarations(_, next))
+ }
+ collectDeclarations(plan, Nil)
+ if (declarationsByScan.isEmpty) {
+ return plan
+ }
+
+ plan.transformUp {
+ case scan: BasicScanExecTransformer if
declarationsByScan.containsKey(scan) =>
+ val subfields = declarationsByScan.get(scan)
+ logDebug(s"Applying scan map-key pruning: $subfields")
+ val newScan = scan.withRequiredMapSubfields(subfields)
+ newScan.copyTagsFrom(scan)
+ newScan
+ }
+ }
+
+ /** A native Parquet scan that supports the declaration and has none yet. */
+ private def isPrunableParquetScan(scan: BasicScanExecTransformer): Boolean =
+ scan.supportsMapKeyPruning && scan.requiredMapSubfields.isEmpty &&
+ scan.fileFormat == ReadFileFormat.ParquetReadFormat
+
+ /**
+ * The operators a chain may consist of: Filters and Projects, which use the
input rows through
+ * their predicate or project list, and Generate, which forwards its
requiredChildOutput and uses
+ * the input only through the generator. ColumnarPartialProjectExec and
+ * ColumnarPartialGenerateExec are deliberately not among them: part of
their work is in a field
+ * the rule cannot see.
+ */
+ private def isChainOperator(node: SparkPlan): Boolean = node match {
+ case _: FilterExecTransformerBase | _: FilterExec => true
+ case _: ProjectExecTransformerBase | _: ProjectExec => true
+ case _: GenerateExecTransformer | _: GenerateExec => true
+ case _ => false
+ }
+
+ /**
+ * Follows 'attr' from its scan (whose pushed-down 'filters' are visited
first) up the chain and
+ * returns the paths to declare, or None when the attribute must stay whole.
+ *
+ * 'liveIds' holds the ExprIds that still carry map data of the attribute,
with the path from the
+ * attribute to what the id stands for: the attribute itself with an empty
path, a bare alias (`m
+ * AS mm`) with an empty path, or a struct-field alias (`c.m AS _extract_m`,
which Spark's
+ * NestedColumnAliasing inserts) with path [m]. A reference to a live id
resolves by prepending
+ * that path: `_extract_m['a']` is `c.m["a"]`. The chain is complete once no
id is live.
+ */
+ private def pathsToDeclare(
+ filters: Seq[Expression],
+ chain: List[SparkPlan],
+ attr: Attribute): Option[Seq[List[Elem]]] = {
+ val paths = new CollectedPaths
+ var liveIds: Map[ExprId, List[Elem]] = Map(attr.exprId -> Nil)
+
+ def recordUsesIn(e: Expression, nullCheck: Boolean): Unit =
+ recordUses(liveIds, paths, e, nullCheck)
+
+ // A project list decides what stays live. An entry that resolves to a
path under a live id
+ // stays live under its own id when the path is key-free and still holds a
map: a forwarded
+ // `m#3`, a bare alias `m#3 AS mm#12`, or Spark's `c#3.m AS _extract_m#20`
(recording the path
+ // instead would declare all of it and defeat pruning below). A keyed
path, or a path with no
+ // map under it (`c#3.s.v AS _extract_v#8`), is recorded and needs no
further tracking: the
+ // entry then only carries data the declared path keeps. Anything else is
a computation, a use.
+ def applyProjectList(list: Seq[NamedExpression]): Unit = {
+ val next = mutable.LinkedHashMap[ExprId, List[Elem]]()
+ list.foreach {
+ entry =>
+ val expr = entry match {
+ case Alias(child, _) => child
+ case other => other
+ }
+ resolveSubfieldPath(expr) match {
+ case Some((root, path)) if liveIds.contains(root.exprId) =>
+ val full = liveIds(root.exprId) ++ path
+ if (!hasKeyLookup(full) && containsMapType(entry.dataType)) {
+ next(entry.exprId) = full
+ } else {
+ recordPath(paths, full, nullCheck = false)
+ }
+ case _ => recordUsesIn(expr, nullCheck = false)
+ }
+ }
+ liveIds = next.toMap
+ }
+
+ // A Generate uses its input only through the generator
(requiredChildOutput is a forward),
+ // and outputs requiredChildOutput plus the generated columns. Whatever it
does not output
+ // cannot be referenced above it, so it may also end the chain.
+ def applyGenerate(node: SparkPlan, generator: Expression): Unit = {
+ recordUsesIn(generator, nullCheck = false)
+ val forwarded = node.output.map(_.exprId).toSet
+ liveIds = liveIds.filter { case (id, _) => forwarded.contains(id) }
+ }
+
+ // Filters pushed into the scan (PushDownFilterToScan) are evaluated
natively on the scan
+ // output; they read the map like the Filter above them does.
+ filters.foreach(recordUsesIn(_, nullCheck = false))
+ chain.iterator.takeWhile(_ => liveIds.nonEmpty &&
!paths.abandoned).foreach {
+ case f: FilterExecTransformerBase => recordUsesIn(f.cond, nullCheck =
false)
+ case f: FilterExec => recordUsesIn(f.condition, nullCheck = false)
+ case p: ProjectExecTransformerBase => applyProjectList(p.list)
+ case p: ProjectExec => applyProjectList(p.projectList)
+ case g: GenerateExecTransformer => applyGenerate(g, g.generator)
+ case g: GenerateExec => applyGenerate(g, g.generator)
+ }
+ // Still live when the chain ends: the attribute (or an alias holding map
data of it) reaches
+ // an operator the rule does not follow, or the fragment's output. Stay
whole.
+ if (paths.abandoned || liveIds.nonEmpty) {
+ return None
+ }
+
+ val value = paths.valuePaths.toSeq
+ // A null check needs no map entries, so it is dropped when a value path
has it as a prefix
+ // (`startsWith` compares element lists: `m` is a prefix of `m["a"].t`):
+ // WHERE m IS NOT NULL AND m['a'].t > 0 -> declare m["a"].t only;
declaring `m` would keep
+ // the whole column.
+ // WHERE m['b'] IS NULL, SELECT m['a'].t -> declare both; without
m["b"] the reader would
+ // drop key b and the check
would see null.
+ val nullOnly = paths.nullCheckPaths.toSeq.filterNot(nc =>
value.exists(_.startsWith(nc)))
+ val all = value ++ nullOnly
+ // Declare only if the reader can then skip something. A key-free path
keeps everything under
+ // it (Velox lets a shorter path dominate longer ones), so `size(c.m)`
next to `c.m['a']`, or
+ // `m IS NOT NULL` next to `m['a'] IS NULL`, reads all of the map either
way; declaring would
+ // only rewrite the scan for nothing.
+ val keyFree = all.filterNot(hasKeyLookup)
+ val prunes = all.exists(p => hasKeyLookup(p) && !keyFree.exists(prefix =>
p.startsWith(prefix)))
+ if (prunes) Some(all) else None
+ }
+
+ /**
+ * Classifies one expression: a subfield path under a live id is recorded,
anything else is
+ * decomposed into its children. The largest sub-expression that is a path
is the informative one,
+ * so the whole expression is tried first. `cardinality(m)` is not a path;
the descent reaches
+ * `m`, whose empty path means the map is used whole, and recordPath()
abandons. For
+ * `cardinality(c.m)` the descent records the key-free path `c.m`, which at
emission dominates
+ * every keyed path below it and so suppresses the declaration. 'nullCheck'
is set for the operand
+ * of IsNull / IsNotNull.
+ */
+ private def recordUses(
+ liveIds: Map[ExprId, List[Elem]],
+ paths: CollectedPaths,
+ e: Expression,
+ nullCheck: Boolean): Unit = e match {
+ case n: IsNotNull => recordUses(liveIds, paths, n.child, nullCheck = true)
+ case n: IsNull => recordUses(liveIds, paths, n.child, nullCheck = true)
+ case _ =>
+ resolveSubfieldPath(e) match {
+ case Some((root, path)) =>
+ liveIds.get(root.exprId).foreach(prefix => recordPath(paths, prefix
++ path, nullCheck))
+ case None => e.children.foreach(recordUses(liveIds, paths, _,
nullCheck = false))
+ }
+ }
+
+ /**
+ * Records a reference to 'path', or abandons the attribute when the path
cannot be declared: the
+ * empty path, i.e. the attribute itself used whole (`size(m)`, `first(m)`),
unless it is a null
+ * check; or a path naming an empty field, which the native side cannot
represent. Paths come from
+ * resolved expressions, so they fit the schema; Velox keeps everything
under them.
+ */
+ private def recordPath(paths: CollectedPaths, path: List[Elem], nullCheck:
Boolean): Unit = {
+ val emptyName = path.exists {
+ case Field(name) => name.isEmpty
+ case _ => false
+ }
+ if (emptyName || (path.isEmpty && !nullCheck)) {
+ paths.abandoned = true
+ } else if (nullCheck) {
+ paths.nullCheckPaths += path
+ } else {
+ paths.valuePaths += path
+ }
+ }
+
+ /** True if 'dt' is or contains a MapType, so an attribute of this type is a
candidate. */
+ private def containsMapType(dt: DataType): Boolean = dt match {
+ case _: MapType => true
+ case s: StructType => s.exists(f => containsMapType(f.dataType))
+ case a: ArrayType => containsMapType(a.elementType)
+ case _ => false
+ }
+
+ /**
+ * Resolves 'e' to a subfield path: an AttributeReference at the root,
followed by GetStructField
+ * and constant-key GetMapValue / ElementAt steps. Catalyst nests these
extractors outside-in, so
+ * the match recurses to the attribute and appends one element per level on
the way back, leaving
+ * the elements in root-to-leaf order. None for anything else, for example
`cardinality(m)` or a
+ * lookup with a non-constant key.
+ */
+ private def resolveSubfieldPath(e: Expression): Option[(AttributeReference,
List[Elem])] =
+ e match {
+ case a: AttributeReference => Some((a, Nil))
+ case g: GetStructField =>
+ // The schema's spelling, not the user's: with caseSensitive=false,
`c.M` resolves to field
+ // `m`, and the path must name the field as the file stores it.
+ val fieldName =
g.child.dataType.asInstanceOf[StructType](g.ordinal).name
+ resolveSubfieldPath(g.child).map { case (a, p) => (a, p :+
(Field(fieldName): Elem)) }
+ case g: GetMapValue => resolveMapLookup(g.child, g.key)
+ case ea: ElementAt if ea.left.dataType.isInstanceOf[MapType] =>
+ resolveMapLookup(ea.left, ea.right)
+ case _ => None
+ }
+
+ /** One map lookup step of resolveSubfieldPath: the path to 'map' plus the
constant 'key'. */
+ private def resolveMapLookup(
+ map: Expression,
+ key: Expression): Option[(AttributeReference, List[Elem])] = {
+ val keyType = map.dataType.asInstanceOf[MapType].keyType
+ constantKeyElement(key, keyType).flatMap(
+ k => resolveSubfieldPath(map).map { case (a, p) => (a, p :+ k) })
+ }
+
+ /**
+ * The key of a lookup into a map with keys of 'keyType', as a path element,
if the key expression
+ * is foldable (a constant under Catalyst's definition): `m['a']`,
`m[concat('a', '')]` and
+ * `m[upper('a')]` qualify, a key that depends on the input row does not.
The element kind follows
+ * the map's key type, which is what Velox checks the subfield against:
string keys become StrKey,
+ * integral keys LongKey, any other key type (date, decimal, ...) has no
Velox subscript and
+ * yields None.
+ */
+ private def constantKeyElement(key: Expression, keyType: DataType):
Option[Elem] = {
+ if (!key.foldable) {
+ return None
+ }
+ val value =
+ try key.eval(EmptyRow)
+ catch { case NonFatal(_) => return None }
+ if (value == null) {
+ return None
+ }
+ keyType match {
+ case StringType =>
+ // The key is compared as bytes by Spark and by the reader, but
travels as a string.
+ // Invalid UTF-8 (a key built from a binary cast) does not survive the
round trip, so
+ // such a key cannot be declared.
+ val bytes = value.asInstanceOf[UTF8String].getBytes
+ val rendered = value.toString
+ if (java.util.Arrays.equals(rendered.getBytes(StandardCharsets.UTF_8),
bytes)) {
+ Some(StrKey(rendered))
+ } else {
+ None
+ }
+ case ByteType | ShortType | IntegerType | LongType =>
+ Some(LongKey(value.asInstanceOf[Number].longValue()))
+ case _ => None
+ }
+ }
+
+ private def hasKeyLookup(path: Seq[Elem]): Boolean =
+ path.exists(e => e.isInstanceOf[StrKey] || e.isInstanceOf[LongKey])
+
+ /**
+ * Renders one attribute's paths as [[SubfieldPath]]s. Column and field
names go through
+ * ConverterUtils.normalizeColName, exactly like the names of the scan's
Substrait schema do when
+ * the plan is built, so the declaration and the schema always agree,
including for non-ASCII
+ * names that only Java's full lowercasing changes. Transport is structural,
so no character of a
+ * name or key needs quoting; toString on the result gives the Velox
Subfield syntax, for example
+ * `c.m["k1"].f1`. The paths are sorted by that rendering, so two scans
declaring the same set
+ * compare equal whatever order the walk found the paths in, and exchange
reuse is not lost.
+ */
+ private def renderSubfieldPaths(
+ attr: Attribute,
+ paths: Seq[List[Elem]]): (String, Seq[SubfieldPath]) = {
+ val colName = ConverterUtils.normalizeColName(attr.name)
+ val rendered = paths.map {
+ p =>
+ SubfieldPath(
+ colName,
+ p.map {
+ case Field(n) => Field(ConverterUtils.normalizeColName(n))
+ case other => other
+ })
+ }
+ (colName, rendered.sortBy(_.toString))
+ }
+}
diff --git
a/backends-velox/src/test/scala/org/apache/gluten/execution/ScanMapKeyPruningSuite.scala
b/backends-velox/src/test/scala/org/apache/gluten/execution/ScanMapKeyPruningSuite.scala
new file mode 100644
index 0000000000..4c54941591
--- /dev/null
+++
b/backends-velox/src/test/scala/org/apache/gluten/execution/ScanMapKeyPruningSuite.scala
@@ -0,0 +1,677 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.gluten.execution
+
+import org.apache.gluten.config.{GlutenConfig, VeloxConfig}
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.execution.{BaseScriptTransformationExec, SparkPlan}
+import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
+import org.apache.spark.sql.functions.{col, lit, map, struct, sum}
+import org.apache.spark.sql.types.StringType
+import org.apache.spark.unsafe.types.UTF8String
+
+/**
+ * Every case runs with adaptive execution off and on. Most cases are rows of
one table: a fixture,
+ * a query and the exact declaration expected on each native scan, where an
empty declaration means
+ * the scan must be left whole. The class comment of ScanMapKeyPruning lists
which shapes are
+ * supported; the cases here follow that list.
+ */
+class ScanMapKeyPruningSuite extends VeloxWholeStageTransformerSuite {
+ override protected val resourcePath: String = "/tpch-data-parquet"
+ override protected val fileFormat: String = "parquet"
+
+ override protected def sparkConf: SparkConf = super.sparkConf
+ .set("spark.unsafe.exceptionOnMemoryLeak", "true")
+
+ private val pruningFlag = VeloxConfig.SCAN_MAP_KEY_PRUNING_ENABLED.key
+ private val noBroadcast = "spark.sql.autoBroadcastJoinThreshold" -> "-1"
+
+ //
---------------------------------------------------------------------------------------------
+ // Fixtures: each writes a parquet table and hands its path to the body.
+ //
---------------------------------------------------------------------------------------------
+
+ private type Fixture = (String => Unit) => Unit
+
+ private def table(rows: Long, columns: String*): Fixture = f =>
+ withTempPath {
+ path =>
+ spark.range(0, rows).selectExpr(("id" +: columns):
_*).write.parquet(path.getCanonicalPath)
+ f(path.getCanonicalPath)
+ }
+
+ /** m: MAP<STRING, STRUCT<s STRING, t BIGINT>> with keys a, b, c. */
+ private val mapTable = table(
+ 10000,
+ "map('a', named_struct('s', concat('v', cast(id % 7 as string)), 't', id),
" +
+ "'b', named_struct('s', 'x', 't', id * 2), " +
+ "'c', named_struct('s', 'y', 't', id + 1)) as m"
+ )
+
+ /** m: MAP<STRING, BIGINT>, a map whose values are plain numbers. */
+ private val scalarMapTable = table(1000, "map('a', id, 'b', id * 2, 'c', id
+ 1) as m")
+
+ /** m: MAP<BIGINT, STRUCT<s, t>>. */
+ private val longKeyTable = table(
+ 10000,
+ "map(1L, named_struct('s', concat('v', cast(id % 7 as string)), 't', id),
" +
+ "2L, named_struct('s', 'x', 't', id * 2)) as m")
+
+ /** c: STRUCT<m: MAP<STRING, BIGINT>, v: BIGINT>. */
+ private val nestedMapTable =
+ table(1000, "named_struct('m', map('a', id, 'b', id * 2), 'v', id) as c")
+
+ /** c: STRUCT<m: MAP<STRING, BIGINT>, s: STRUCT<n: MAP<STRING, BIGINT>, v:
BIGINT>>. */
+ private val siblingMapTable = table(
+ 1000,
+ "named_struct('m', map('a', id, 'b', id * 2), " +
+ "'s', named_struct('n', map('p', id), 'v', id)) as c")
+
+ /** m: MAP<STRING, MAP<STRING, BIGINT>>. */
+ private val mapOfMapTable =
+ table(1000, "map('a', map('x', id, 'y', id * 2), 'b', map('z', id)) as m")
+
+ /** m: MAP<STRING, ARRAY<BIGINT>>. */
+ private val arrayMapTable = table(500, "map('a', array(id, id + 1), 'b',
array(id * 2)) as m")
+
+ /** m as in mapTable plus n: MAP<STRING, BIGINT>. */
+ private val twoMapTable = table(
+ 10000,
+ "map('a', named_struct('s', concat('v', cast(id % 7 as string)), 't', id),
" +
+ "'b', named_struct('s', 'x', 't', id * 2)) as m",
+ "map('x', id, 'y', id * 2) as n"
+ )
+
+ // Built from code points rather than literals or unicode escapes, which
scalastyle rejects.
+ private val umlautUpper = 0xc4.toChar // A with diaeresis
+ private val umlautLower = 0xe4.toChar // a with diaeresis
+
+ /**
+ * c: STRUCT<[umlautUpper]rger: MAP<STRING, BIGINT>, v: BIGINT>: a field
name that only full
+ * Unicode lowercasing changes, since the ASCII fold leaves the umlaut alone.
+ */
+ private val umlautFieldTable =
+ table(1000, s"named_struct('${umlautUpper}rger', map('a', id, 'b', id *
2), 'v', id) as c")
+
+ /** m: MAP<STRING, BIGINT> whose first key is a single byte that is not
valid UTF-8. */
+ private val invalidUtf8KeyTable =
+ table(100, "map(cast(x'FF' as string), id, 'a', id * 2) as m")
+
+ /** d: MAP<DATE, BIGINT>, e: MAP<DECIMAL(3, 1), BIGINT>: key types without a
Velox subscript. */
+ private val oddKeyTable = table(
+ 1000,
+ "map(DATE '2020-01-01', id, DATE '2020-01-02', id * 2) as d",
+ "map(CAST(1.5 AS DECIMAL(3, 1)), id, CAST(2.5 AS DECIMAL(3, 1)), id * 2)
as e")
+
+ //
---------------------------------------------------------------------------------------------
+ // Plan inspection.
+ //
---------------------------------------------------------------------------------------------
+
+ /**
+ * The declaration of every native scan in the executed plan (query stages
and commands included),
+ * each rendered in Velox Subfield syntax. At least one native scan must
exist: a "nothing
+ * declared" assertion over a plan whose scan fell back to Spark would prove
nothing.
+ */
+ private def declaredPerScan(df: DataFrame): Seq[Map[String, Set[String]]] = {
+ val scans = getExecutedPlan(df).collect { case scan:
BasicScanExecTransformer => scan }
+ assert(scans.nonEmpty, s"expected a native
scan:\n${df.queryExecution.executedPlan}")
+ scans.map(_.requiredMapSubfields.map { case (c, paths) => c ->
paths.map(_.toString).toSet })
+ }
+
+ /** The declaration over all native scans, for single-scan plans. */
+ private def declared(df: DataFrame): Map[String, Set[String]] =
declaredPerScan(df).flatten.toMap
+
+ private def planHas(df: DataFrame, cls: Class[_ <: SparkPlan]): Boolean =
+ getExecutedPlan(df).exists(cls.isInstance)
+
+ /** Per-scan declarations compared regardless of scan order. */
+ private def multiset[T](xs: Seq[T]): Map[T, Int] = xs.groupBy(identity).map {
+ case (k, v) => k -> v.size
+ }
+
+ //
---------------------------------------------------------------------------------------------
+ // Table-driven cases.
+ //
---------------------------------------------------------------------------------------------
+
+ /**
+ * One case: 'sql' over 'fixture', run with the flag on and compared with
vanilla Spark, must
+ * leave exactly 'expected' on the native scans (one entry per scan,
order-free; an empty map is a
+ * scan left whole) and, if given, must contain an operator of type
'requires' in its plan.
+ */
+ private case class Case(
+ name: String,
+ fixture: Fixture,
+ sql: String => String,
+ expected: Seq[Map[String, Set[String]]],
+ conf: Seq[(String, String)] = Nil,
+ requires: Option[Class[_ <: SparkPlan]] = None,
+ noFallBack: Boolean = true,
+ compare: Boolean = true,
+ before: () => Unit = () => ())
+
+ private def m(paths: String*): Map[String, Set[String]] = Map("m" ->
paths.toSet)
+ private def c(paths: String*): Map[String, Set[String]] = Map("c" ->
paths.toSet)
+ private val whole: Map[String, Set[String]] = Map.empty
+
+ private val cases = Seq(
+ // ----- supported shapes: the map is dropped by a Project before anything
else sees it -----
+ Case(
+ "keyed filter with aggregates over keyed values",
+ mapTable,
+ p => s"SELECT count(*), sum(m['a'].t), max(m['a'].s) FROM parquet.`$p`
WHERE m['a'].s = 'v3'",
+ Seq(m("m[\"a\"].s", "m[\"a\"].t"))
+ ),
+ Case(
+ "filter only, map dropped above the filter",
+ mapTable,
+ p => s"SELECT id FROM parquet.`$p` WHERE m['a'].s = 'v3'",
+ Seq(m("m[\"a\"].s"))),
+ Case(
+ "count only",
+ mapTable,
+ p => s"SELECT count(*) FROM parquet.`$p` WHERE m['a'].s = 'v3'",
+ Seq(m("m[\"a\"].s"))),
+ Case(
+ "projected keyed value without aggregation",
+ mapTable,
+ p => s"SELECT id, m['a'].t FROM parquet.`$p` WHERE m['a'].s = 'v3'",
+ Seq(m("m[\"a\"].s", "m[\"a\"].t"))
+ ),
+ Case(
+ "several keys",
+ mapTable,
+ p => s"SELECT sum(m['a'].t + m['b'].t) FROM parquet.`$p` WHERE m['c'].s
= 'y'",
+ Seq(m("m[\"a\"].t", "m[\"b\"].t", "m[\"c\"].s"))),
+ Case(
+ "constant key expressions are evaluated",
+ mapTable,
+ p => s"SELECT sum(m[concat('a', '')].t) FROM parquet.`$p` WHERE
m[upper('a')].s = 'v3'",
+ Seq(m("m[\"a\"].t", "m[\"A\"].s"))
+ ),
+ Case(
+ "element_at with a constant key",
+ mapTable,
+ p =>
+ s"SELECT sum(element_at(m, 'a').t) FROM parquet.`$p` " +
+ s"WHERE element_at(m, 'a').s = 'v5'",
+ Seq(m("m[\"a\"].s", "m[\"a\"].t"))
+ ),
+ Case(
+ "inferred null check next to a keyed filter is dropped",
+ mapTable,
+ p =>
+ s"SELECT count(*), sum(m['a'].t) FROM parquet.`$p` " +
+ s"WHERE m IS NOT NULL AND m['a'].s = 'v3'",
+ Seq(m("m[\"a\"].s", "m[\"a\"].t"))
+ ),
+ Case(
+ "null check on a keyed path alone",
+ mapTable,
+ p => s"SELECT count(*) FROM parquet.`$p` WHERE m['a'] IS NULL",
+ Seq(m("m[\"a\"]"))),
+ Case(
+ "long keys",
+ longKeyTable,
+ p => s"SELECT count(*), sum(m[1].t) FROM parquet.`$p` WHERE m[1].s =
'v3'",
+ Seq(m("m[1].s", "m[1].t"))),
+ Case(
+ "scalar-valued map",
+ scalarMapTable,
+ p => s"SELECT sum(m['a']) FROM parquet.`$p` WHERE m['b'] > 10",
+ Seq(m("m[\"a\"]", "m[\"b\"]"))),
+ Case(
+ "nested map value used whole under a key",
+ mapOfMapTable,
+ p => s"SELECT sum(cardinality(m['a'])), sum(m['a']['y']) FROM
parquet.`$p`",
+ Seq(m("m[\"a\"]", "m[\"a\"][\"y\"]"))
+ ),
+ Case(
+ "map nested in a struct, through Spark's nested-column alias",
+ nestedMapTable,
+ p => s"SELECT sum(c.m['a']), sum(c.m['b']) FROM parquet.`$p`",
+ Seq(c("c.m[\"a\"]", "c.m[\"b\"]"))
+ ),
+ Case(
+ "sibling struct fields are declared alongside the keyed map",
+ siblingMapTable,
+ p => s"SELECT sum(c.m['a']), sum(c.s.v), sum(cardinality(c.s.n)) FROM
parquet.`$p`",
+ Seq(c("c.m[\"a\"]", "c.s.v", "c.s.n"))
+ ),
+ Case(
+ "case-insensitive field access renders the schema's spelling",
+ nestedMapTable,
+ p => s"SELECT sum(c.M['a']) FROM parquet.`$p`",
+ Seq(c("c.m[\"a\"]")),
+ conf = Seq("spark.sql.caseSensitive" -> "false")
+ ),
+ Case(
+ "collect limit at the root",
+ mapTable,
+ p => s"SELECT id FROM parquet.`$p` WHERE m['a'].s = 'v3' LIMIT 5",
+ Seq(m("m[\"a\"].s"))),
+ Case(
+ "take ordered and project",
+ mapTable,
+ p => s"SELECT id FROM parquet.`$p` WHERE m['a'].s = 'v3' ORDER BY id
LIMIT 5",
+ Seq(m("m[\"a\"].s"))),
+ Case(
+ "window partitioned by a keyed value",
+ scalarMapTable,
+ p => s"SELECT id, rank() OVER (PARTITION BY m['a'] ORDER BY id) AS r
FROM parquet.`$p`",
+ Seq(m("m[\"a\"]"))
+ ),
+ Case(
+ "generate over a keyed value, map still read above the generate",
+ arrayMapTable,
+ p => s"SELECT m['b'], explode(m['a']) FROM parquet.`$p`",
+ Seq(m("m[\"a\"]", "m[\"b\"]")),
+ requires = Some(classOf[GenerateExecTransformer])
+ ),
+ Case(
+ "generate over a keyed value ends the chain when nothing above needs the
map",
+ arrayMapTable,
+ p => s"SELECT explode(m['a']) FROM parquet.`$p`",
+ Seq(m("m[\"a\"]")),
+ requires = Some(classOf[GenerateExecTransformer])
+ ),
+ Case(
+ "non-ASCII struct field name folded like the schema, case-insensitive",
+ umlautFieldTable,
+ // Non-ASCII identifiers must be quoted in Spark SQL.
+ p =>
+ s"SELECT sum(c.`${umlautLower}rger`['a']) FROM parquet.`$p` " +
+ s"WHERE c.`${umlautUpper}RGER`['b'] > 10",
+ // Plain concatenation: scalastyle's parser does not accept \" inside
interpolated strings.
+ Seq(c("c." + umlautLower + "rger[\"a\"]", "c." + umlautLower +
"rger[\"b\"]")),
+ conf = Seq("spark.sql.caseSensitive" -> "false")
+ ),
+ Case(
+ "two map columns on one scan, one pruned and one read whole",
+ twoMapTable,
+ p => s"SELECT sum(m['a'].t), sum(size(n)) FROM parquet.`$p`",
+ Seq(m("m[\"a\"].t"))
+ ),
+ Case(
+ "shuffled join with keyed filters on both sides",
+ mapTable,
+ p =>
+ s"SELECT count(*) FROM parquet.`$p` t1 JOIN parquet.`$p` t2 ON t1.id =
t2.id " +
+ s"WHERE t1.m['a'].s = 'v3' AND t2.m['b'].s = 'x'",
+ Seq(m("m[\"a\"].s"), m("m[\"b\"].s")),
+ conf = Seq(noBroadcast)
+ ),
+ Case(
+ "inlined CTE read twice with different keys pushed below the join",
+ mapTable,
+ p =>
+ s"WITH c AS (SELECT id, m FROM parquet.`$p`) " +
+ s"SELECT sum(t1.m['a'].t + t2.m['b'].t) FROM c t1 JOIN c t2 ON t1.id
= t2.id",
+ Seq(m("m[\"a\"].t"), m("m[\"b\"].t")),
+ conf = Seq(noBroadcast)
+ ),
+ Case(
+ "identical pruned scans keep exchange reuse",
+ mapTable,
+ p =>
+ s"WITH c AS (SELECT id, m['a'].t AS x FROM parquet.`$p` WHERE m['a'].s
= 'v3') " +
+ s"SELECT sum(t1.x + t2.x) FROM c t1 JOIN c t2 ON t1.id = t2.id",
+ Seq(m("m[\"a\"].s", "m[\"a\"].t")),
+ conf = Seq(noBroadcast),
+ requires = Some(classOf[ReusedExchangeExec])
+ ),
+ Case(
+ // The two branches reference the keys in opposite order, so the walk
finds the same paths
+ // in a different order. Spark canonicalizes the rest of the plan
order-free, so only a
+ // declaration kept in walk order would make the scans differ and lose
the reuse.
+ "same declaration found in another order keeps exchange reuse",
+ mapTable,
+ p =>
+ s"SELECT sum(t1.x + t2.x) FROM " +
+ s"(SELECT id, m['a'].t AS x FROM parquet.`$p` " +
+ s"WHERE m['a'].s = 'v3' AND m['b'].s = 'x') t1 " +
+ s"JOIN (SELECT id, m['a'].t AS x FROM parquet.`$p` " +
+ s"WHERE m['b'].s = 'x' AND m['a'].s = 'v3') t2 ON t1.id = t2.id",
+ Seq(m("m[\"a\"].s", "m[\"a\"].t", "m[\"b\"].s")),
+ conf = Seq(noBroadcast),
+ requires = Some(classOf[ReusedExchangeExec])
+ ),
+ Case(
+ "union of keyed projections prunes each branch",
+ mapTable,
+ p =>
+ s"SELECT m['a'].t AS v FROM parquet.`$p` WHERE m['a'].s = 'v3' " +
+ s"UNION ALL SELECT m['b'].t FROM parquet.`$p` WHERE m['c'].s = 'y'",
+ Seq(m("m[\"a\"].s", "m[\"a\"].t"), m("m[\"b\"].t", "m[\"c\"].s"))
+ ),
+ Case(
+ "UDF in a partially offloaded project above the chain",
+ mapTable,
+ p => s"SELECT sum(mk_len(m['a'].s)), sum(id) FROM parquet.`$p` WHERE
m['b'].s = 'x'",
+ Seq(m("m[\"a\"].s", "m[\"b\"].s")),
+ conf = Seq(GlutenConfig.ENABLE_COLUMNAR_PARTIAL_PROJECT.key -> "true"),
+ requires = Some(classOf[ColumnarPartialProjectExec]),
+ before = () => spark.udf.register("mk_len", (s: String) => if (s ==
null) 0 else s.length)
+ ),
+ Case(
+ "script transformation above the chain",
+ mapTable,
+ p =>
+ s"SELECT TRANSFORM(id, m['a'].s) USING 'cat' AS (a STRING, b STRING) "
+
+ s"FROM parquet.`$p` WHERE m['b'].s = 'x'",
+ Seq(m("m[\"a\"].s", "m[\"b\"].s")),
+ requires = Some(classOf[BaseScriptTransformationExec]),
+ noFallBack = false
+ ),
+ // ----- unsupported shapes: the map stays whole -----
+ Case(
+ "whole-map use",
+ mapTable,
+ p => s"SELECT sum(size(m)) FROM parquet.`$p` WHERE m['a'].s = 'v3'",
+ Seq(whole)),
+ Case(
+ "non-constant key",
+ mapTable,
+ p => s"SELECT sum(m[if(id % 2 = 0, 'a', 'b')].t) FROM parquet.`$p`",
+ Seq(whole)),
+ Case(
+ "map in the output",
+ mapTable,
+ p => s"SELECT m FROM parquet.`$p` WHERE m['a'].s = 'v3'",
+ Seq(whole)),
+ Case(
+ "alias still holding the map in the output",
+ nestedMapTable,
+ p => s"SELECT c.m AS mm FROM parquet.`$p` WHERE c.m['a'] > 10",
+ Seq(whole)),
+ Case(
+ "project computing over the whole map",
+ mapTable,
+ p => s"SELECT map_keys(m) FROM parquet.`$p` WHERE m['a'].s = 'v3'",
+ Seq(whole)),
+ Case(
+ "key-free path next to a keyed path prunes nothing",
+ mapTable,
+ p => s"SELECT sum(size(m)), sum(m['a'].t) FROM parquet.`$p`",
+ Seq(whole)),
+ Case(
+ "key-free alias path next to a keyed path prunes nothing",
+ nestedMapTable,
+ p => s"SELECT sum(size(c.m)), sum(c.m['a']) FROM parquet.`$p`",
+ Seq(whole)),
+ Case(
+ "bare null check next to a keyed null check prunes nothing",
+ mapTable,
+ p => s"SELECT count(*) FROM parquet.`$p` WHERE m IS NOT NULL AND m['a']
IS NULL",
+ Seq(whole)
+ ),
+ Case(
+ "generate over the whole map",
+ scalarMapTable,
+ p => s"SELECT id, explode(m) FROM parquet.`$p`",
+ Seq(whole),
+ requires = Some(classOf[GenerateExecTransformer])
+ ),
+ Case(
+ "string key with invalid UTF-8 bytes",
+ invalidUtf8KeyTable,
+ p => s"SELECT sum(m[cast(x'FF' as string)]), sum(m['a']) FROM
parquet.`$p`",
+ Seq(whole),
+ // Vanilla Spark itself returns null for this lookup after the Parquet
round trip while the
+ // native reader finds the entry; only the declaration is checked here.
+ compare = false
+ ),
+ Case(
+ "date keys",
+ oddKeyTable,
+ p => s"SELECT sum(d[DATE '2020-01-01']) FROM parquet.`$p`",
+ Seq(whole),
+ noFallBack = false),
+ Case(
+ "decimal keys",
+ oddKeyTable,
+ p => s"SELECT sum(e[CAST(1.5 AS DECIMAL(3, 1))]) FROM parquet.`$p`",
+ Seq(whole),
+ noFallBack = false),
+ Case(
+ "map forwarded through a shuffle and read above a join",
+ mapTable,
+ p =>
+ s"SELECT sum(element_at(t1.m, 'a').t) FROM parquet.`$p` t1 " +
+ s"JOIN parquet.`$p` t2 ON t1.id = t2.id",
+ Seq(whole, whole),
+ conf = Seq(noBroadcast)
+ ),
+ Case(
+ "maps forwarded through a self join, one read whole",
+ twoMapTable,
+ p =>
+ s"WITH c AS (SELECT id, m, n FROM parquet.`$p`) " +
+ s"SELECT t1.m, t1.n['x'], t2.m['b'], t2.n['y'] FROM c t1 JOIN c t2
ON t1.id = t2.id",
+ Seq(whole),
+ conf = Seq(noBroadcast),
+ requires = Some(classOf[ReusedExchangeExec])
+ ),
+ Case(
+ "sort by a keyed value forwards the map through the shuffle",
+ scalarMapTable,
+ p => s"SELECT id FROM parquet.`$p` WHERE m['b'] > 10 ORDER BY m['a']",
+ Seq(whole)
+ )
+ ) ++ Seq("true", "false").map {
+ nativeUnion =>
+ Case(
+ s"union forwarding the map (native union = $nativeUnion)",
+ mapTable,
+ p =>
+ s"SELECT size(m), m['b'].s FROM (SELECT m FROM parquet.`$p` WHERE
m['a'].s = 'v0' " +
+ s"UNION ALL SELECT m FROM parquet.`$p` WHERE m['c'].s = 'y')",
+ Seq(whole, whole),
+ conf = Seq(GlutenConfig.NATIVE_UNION_ENABLED.key -> nativeUnion)
+ )
+ }
+
+ /** The set of map keys per row of a plan whose single output column is a
string-keyed map. */
+ private def keysPerRow(plan: SparkPlan): Set[Set[String]] =
+ plan
+ .executeCollect()
+
.map(_.getMap(0).keyArray().toArray[UTF8String](StringType).map(_.toString).toSet)
+ .toSet
+
+ /** 'plan' with every native scan declaring only key "a" of column m,
bypassing the rule. */
+ private def declareKeyA(plan: SparkPlan): SparkPlan = plan.transform {
+ case scan: BasicScanExecTransformer =>
+ scan.withRequiredMapSubfields(
+ Map("m" -> Seq(SubfieldPath("m",
Seq(SubfieldElement.StringKey("a"))))))
+ }
+
+ test("the native reader applies a declaration: undeclared keys are not
read") {
+ // Attaches a declaration to the scan directly, bypassing the rule, and
reads the map whole.
+ // Only the declared key may come back; if the transport or the native
side ignored the
+ // declaration, all three keys would.
+ mapTable {
+ path =>
+ withSQLConf("spark.sql.adaptive.enabled" -> "false") {
+ val df = spark.read.parquet(path).select("m")
+ val plan = df.queryExecution.executedPlan
+ assert(planHas(df, classOf[BasicScanExecTransformer]), s"expected a
native scan:\n$plan")
+ assert(keysPerRow(plan) == Set(Set("a", "b", "c")))
+ val pruned = declareKeyA(plan)
+ assert(keysPerRow(pruned) == Set(Set("a")), s"declaration not
applied:\n$pruned")
+ }
+ }
+ }
+
+ test("a keyed filter at the scan keeps the declaration effective, element_at
included") {
+ // The filter is pushed to the scan and reaches Velox as the scan's
remaining filter. Velox
+ // adds the subfields that filter reads to the declaration: for a
pushdown-capable lookup the
+ // key path, for anything else the bare column, which keeps every entry.
The Velox backend
+ // emits element_at on a map as get_map_value, so both spellings
contribute m["a"].s and only
+ // the declared key comes back.
+ mapTable {
+ path =>
+ withSQLConf("spark.sql.adaptive.enabled" -> "false") {
+ Seq("element_at(m, 'a').s = 'v5'", "m['a'].s = 'v5'").foreach {
+ predicate =>
+ val df = spark.read.parquet(path).where(predicate).select("m")
+ val plan = df.queryExecution.executedPlan
+ assert(
+ planHas(df, classOf[BasicScanExecTransformer]),
+ s"expected a native scan:\n$plan")
+ assert(keysPerRow(plan) == Set(Set("a", "b", "c")), predicate)
+ val pruned = declareKeyA(plan)
+ assert(
+ keysPerRow(pruned) == Set(Set("a")),
+ s"$predicate read undeclared keys:\n$pruned")
+ }
+ }
+ }
+ }
+
+ Seq(false, true).foreach {
+ adaptive =>
+ val mode = s"adaptive execution ${if (adaptive) "on" else "off"}"
+ val modeConf = "spark.sql.adaptive.enabled" -> adaptive.toString
+
+ cases.foreach {
+ cs =>
+ test(s"${cs.name} ($mode)") {
+ cs.before()
+ cs.fixture {
+ path =>
+ withSQLConf((Seq(pruningFlag -> "true", modeConf) ++ cs.conf):
_*) {
+ runQueryAndCompare(
+ cs.sql(path),
+ compareResult = cs.compare,
+ noFallBack = cs.noFallBack) {
+ df =>
+ val plan = df.queryExecution.executedPlan
+ cs.requires.foreach {
+ cls => assert(planHas(df, cls), s"expected
${cls.getSimpleName}:\n$plan")
+ }
+ val actual = declaredPerScan(df)
+ assert(
+ multiset(actual) == multiset(cs.expected),
+ s"declared $actual, expected ${cs.expected}\n$plan")
+ }
+ }
+ }
+ }
+ }
+
+ // ----- cases that do not fit the table -----
+
+ test(s"pruning is off by default ($mode)") {
+ mapTable {
+ path =>
+ withSQLConf(modeConf) {
+ runQueryAndCompare(
+ s"SELECT sum(m['a'].t) FROM parquet.`$path` WHERE m['a'].s =
'v3'") {
+ df => assert(declared(df).isEmpty)
+ }
+ }
+ }
+ }
+
+ test(s"keys with quotes or backslashes survive the structural transport
($mode)") {
+ // Built with the DataFrame API so the keys reach the plan verbatim,
without SQL
+ // string-literal escaping in between.
+ val quoteKey = "q\"k"
+ val backslashKey = "b\\s"
+ withTempPath {
+ dir =>
+ val file = dir.getCanonicalPath
+ spark
+ .range(0, 100)
+ .select(
+ col("id"),
+ map(
+ lit(quoteKey),
+ struct(lit("v").as("s"), col("id").as("t")),
+ lit(backslashKey),
+ struct(lit("w").as("s"), (col("id") * 2).as("t"))).as("m"))
+ .write
+ .parquet(file)
+ def run(): (Row, Set[String]) = {
+ val df = spark.read
+ .parquet(file)
+ .select(
+ sum(col("m").getItem(quoteKey).getField("t")).as("q"),
+ sum(col("m").getItem(backslashKey).getField("t")).as("b"))
+ val row = df.collect().head
+ (row, declared(df).getOrElse("m", Set.empty))
+ }
+ var expected: Row = null
+ withSQLConf(pruningFlag -> "false", modeConf) {
+ val (row, paths) = run()
+ assert(paths.isEmpty)
+ expected = row
+ }
+ withSQLConf(pruningFlag -> "true", modeConf) {
+ val (row, paths) = run()
+ assert(paths == Set("m[\"q\\\"k\"].t", "m[\"b\\\\s\"].t"),
s"got: $paths")
+ assert(row == expected, s"pruned result $row differs from
$expected")
+ }
+ }
+ }
+
+ test(s"writing the whole map keeps it whole ($mode)") {
+ // The Project feeding the write forwards the map, so the chain ends
with it live.
+ mapTable {
+ path =>
+ withTable("mk_write_whole") {
+ spark.sql(
+ "CREATE TABLE mk_write_whole " +
+ "(id BIGINT, m MAP<STRING, STRUCT<s: STRING, t: BIGINT>>)
USING parquet")
+ withSQLConf(pruningFlag -> "true", modeConf) {
+ val df = spark.sql(
+ s"INSERT INTO mk_write_whole SELECT id, m FROM
parquet.`$path` " +
+ s"WHERE m['a'].s = 'v3'")
+ assert(declaredPerScan(df) == Seq(whole), s"map must stay
whole: ${declared(df)}")
+ }
+ // Every written map still holds all three keys.
+ checkAnswer(
+ spark.sql("SELECT count(*) - sum(CASE WHEN size(m) = 3 THEN 1
ELSE 0 END) " +
+ "FROM mk_write_whole"),
+ Row(0L) :: Nil)
+ }
+ }
+ }
+
+ test(s"writing keyed values prunes the scan ($mode)") {
+ // The Project feeding the write computes m['a'].t and drops the map.
+ mapTable {
+ path =>
+ withTable("mk_write_keyed") {
+ spark.sql("CREATE TABLE mk_write_keyed (id BIGINT, t BIGINT)
USING parquet")
+ withSQLConf(pruningFlag -> "true", modeConf) {
+ val df = spark.sql(
+ s"INSERT INTO mk_write_keyed SELECT id, m['a'].t FROM
parquet.`$path` " +
+ s"WHERE m['a'].s = 'v3'")
+ assert(
+ declared(df) == m("m[\"a\"].s", "m[\"a\"].t"),
+ s"expected pruning below the write: ${declared(df)}")
+ }
+ checkAnswer(
+ spark.sql("SELECT count(*), sum(t) FROM mk_write_keyed"),
+ spark.sql(
+ s"SELECT count(*), sum(m['a'].t) FROM parquet.`$path` WHERE
m['a'].s = 'v3'"))
+ }
+ }
+ }
+ }
+}
diff --git
a/backends-velox/src/test/scala/org/apache/spark/sql/execution/GlutenHiveUDFSuite.scala
b/backends-velox/src/test/scala/org/apache/spark/sql/execution/GlutenHiveUDFSuite.scala
index c2ee2f0dbe..9657d1033f 100644
---
a/backends-velox/src/test/scala/org/apache/spark/sql/execution/GlutenHiveUDFSuite.scala
+++
b/backends-velox/src/test/scala/org/apache/spark/sql/execution/GlutenHiveUDFSuite.scala
@@ -16,8 +16,8 @@
*/
package org.apache.spark.sql.execution
-import org.apache.gluten.config.GlutenConfig
-import org.apache.gluten.execution.{ColumnarPartialGenerateExec,
ColumnarPartialProjectExec, GlutenQueryComparisonTest}
+import org.apache.gluten.config.{GlutenConfig, VeloxConfig}
+import org.apache.gluten.execution.{BasicScanExecTransformer,
ColumnarPartialGenerateExec, ColumnarPartialProjectExec,
GlutenQueryComparisonTest}
import org.apache.gluten.expression.UDFMappings
import org.apache.gluten.udf.{CustomerUDF, DuplicateArray}
import org.apache.gluten.udtf.{ConditionalOutputUDTF, CustomerUDTF,
NoInputUDTF, SimpleUDTF}
@@ -135,6 +135,38 @@ class GlutenHiveUDFSuite extends GlutenQueryComparisonTest
with SQLTestUtils {
}
}
+ test("a partial generate over a hive udtf disables map-key pruning") {
+ // The map is forwarded through the partial generate and read above it, so
ScanMapKeyPruning
+ // finds it still live when its Filter / Project chain ends and leaves the
scan whole.
+ withTempFunction("simpleUDTF") {
+ sql(s"CREATE TEMPORARY FUNCTION simpleUDTF AS
'${classOf[SimpleUDTF].getName}'")
+ withTempPath {
+ path =>
+ spark
+ .range(0, 100)
+ .selectExpr("id", "map('a', id, 'b', id * 2) as m")
+ .write
+ .parquet(path.getCanonicalPath)
+ withTempView("map_udtf_src") {
+
spark.read.parquet(path.getCanonicalPath).createOrReplaceTempView("map_udtf_src")
+ withSQLConf(VeloxConfig.SCAN_MAP_KEY_PRUNING_ENABLED.key ->
"true") {
+ runQueryAndCompare(
+ "select col0, m['a'] from map_udtf_src lateral view
simpleUDTF(id) as col0") {
+ df =>
+ checkOperatorMatch[ColumnarPartialGenerateExec](df)
+ val declared = getExecutedPlan(df).collect {
+ case scan: BasicScanExecTransformer =>
scan.requiredMapSubfields
+ }.filter(_.nonEmpty)
+ assert(
+ declared.isEmpty,
+ s"plan with a partial generate must not be pruned:
$declared")
+ }
+ }
+ }
+ }
+ }
+ }
+
test("simple udtf") {
withTempFunction("simpleUDTF") {
sql(s"CREATE TEMPORARY FUNCTION simpleUDTF AS
'${classOf[SimpleUDTF].getName}'")
diff --git a/cpp/velox/CMakeLists.txt b/cpp/velox/CMakeLists.txt
index 71bb0f9a60..fb4b0e4679 100644
--- a/cpp/velox/CMakeLists.txt
+++ b/cpp/velox/CMakeLists.txt
@@ -176,6 +176,7 @@ set(VELOX_SRCS
memory/VeloxMemoryManager.cc
operators/functions/RegistrationAllFunctions.cc
operators/functions/delta/DeltaBitmapAggregator.cc
+ operators/functions/overlay/GetMapValue.cc
operators/functions/overlay/RegisterFunctionOverlay.cc
operators/functions/RowConstructorWithNull.cc
operators/functions/SparkExprToSubfieldFilterParser.cc
diff --git a/cpp/velox/operators/functions/overlay/GetMapValue.cc
b/cpp/velox/operators/functions/overlay/GetMapValue.cc
new file mode 100644
index 0000000000..56570750d5
--- /dev/null
+++ b/cpp/velox/operators/functions/overlay/GetMapValue.cc
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#include "operators/functions/overlay/GetMapValue.h"
+
+#include "velox/expression/VectorFunction.h"
+
+using namespace facebook;
+
+namespace gluten {
+
+void registerSparkGetMapValueFunction(const std::string& name) {
+ velox::exec::registerStatefulVectorFunction(
+ name,
+ SparkGetMapValueFunction::signatures(),
+ [](const std::string&,
+ const std::vector<velox::exec::VectorFunctionArg>& inputArgs,
+ const velox::core::QueryConfig& config) ->
std::shared_ptr<velox::exec::VectorFunction> {
+ VELOX_CHECK_EQ(inputArgs.size(), 2);
+ VELOX_CHECK(inputArgs[0].type->isMap(), "get_map_value expects a map
input");
+ // A per-call instance so the map subscript cache stays private to the
+ // expression, as in velox::functions::registerElementAtFunction.
+ return
std::make_shared<SparkGetMapValueFunction>(config.isExpressionEvaluationCacheEnabled());
+ });
+}
+
+} // namespace gluten
diff --git a/cpp/velox/operators/functions/overlay/GetMapValue.h
b/cpp/velox/operators/functions/overlay/GetMapValue.h
new file mode 100644
index 0000000000..990aca1ddd
--- /dev/null
+++ b/cpp/velox/operators/functions/overlay/GetMapValue.h
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#pragma once
+
+#include "velox/functions/lib/SubscriptUtil.h"
+
+namespace gluten {
+
+/// Spark GetMapValue (m[key]): returns the value stored under 'key', or NULL
+/// when the key is absent. Only the map signature is registered.
+///
+/// Unlike Velox's element_at, which serves arrays as well and therefore keeps
+/// Subscript::canPushdown() == false (a Subfield path cannot express a
+/// negative array index), a map lookup with a constant key is exactly the
+/// path m["k"]: the reader keeps only the entries whose key is "k", and the
+/// lookup yields the same value, or NULL, as on the full map. Reporting the
+/// subscript lets Expr::extractSubfields() produce m["k"] for a remaining
+/// filter such as get_map_value(m, 'k').x = 1 instead of the bare column path
+/// m, which would otherwise force the reader to keep every map entry and
+/// defeat map-key pruning declared through
HiveColumnHandle::requiredSubfields.
+class SparkGetMapValueFunction : public
facebook::velox::functions::SubscriptImpl<
+ /* allowNegativeIndices */ false,
+ /* nullOnNegativeIndices */ false,
+ /* allowOutOfBound */ true,
+ /* indexStartsAtOne */ true> {
+ public:
+ explicit SparkGetMapValueFunction(bool allowCaching) :
SubscriptImpl(allowCaching) {}
+
+ bool canPushdown() const override {
+ return true;
+ }
+
+ static
std::vector<std::shared_ptr<facebook::velox::exec::FunctionSignature>>
signatures() {
+ // map(K,V), K -> V
+ return {facebook::velox::exec::FunctionSignatureBuilder()
+ .typeVariable("K")
+ .typeVariable("V")
+ .returnType("V")
+ .argumentType("map(K,V)")
+ .argumentType("K")
+ .build()};
+ }
+};
+
+/// Registers Spark GetMapValue under 'name'.
+void registerSparkGetMapValueFunction(const std::string& name);
+
+} // namespace gluten
diff --git a/cpp/velox/operators/functions/overlay/RegisterFunctionOverlay.cc
b/cpp/velox/operators/functions/overlay/RegisterFunctionOverlay.cc
index d16e7636bd..d5d4097531 100644
--- a/cpp/velox/operators/functions/overlay/RegisterFunctionOverlay.cc
+++ b/cpp/velox/operators/functions/overlay/RegisterFunctionOverlay.cc
@@ -16,6 +16,7 @@
*/
#include "operators/functions/overlay/RegisterFunctionOverlay.h"
+#include "operators/functions/overlay/GetMapValue.h"
#include "operators/functions/overlay/Round.h"
#include "velox/functions/lib/RegistrationHelpers.h"
@@ -40,6 +41,11 @@ void registerRoundFunction() {
void registerFunctionOverlay() {
registerRoundFunction();
+ // Spark GetMapValue (m[key]) is emitted as get_map_value rather than being
+ // rewritten to Velox's element_at: a map-only subscript can report
+ // canPushdown(), so remaining filters like m['k'].x = v extract m["k"] and
+ // do not defeat map-key pruning.
+ registerSparkGetMapValueFunction("get_map_value");
}
} // namespace gluten
diff --git a/cpp/velox/substrait/SubstraitToVeloxPlan.cc
b/cpp/velox/substrait/SubstraitToVeloxPlan.cc
index ce9de452fb..dc9833663a 100644
--- a/cpp/velox/substrait/SubstraitToVeloxPlan.cc
+++ b/cpp/velox/substrait/SubstraitToVeloxPlan.cc
@@ -35,6 +35,7 @@
#include "utils/VeloxArrowUtils.h"
#include "utils/VeloxWriterUtils.h"
+#include "RequiredSubfieldsExtension.pb.h"
#include "config.pb.h"
#include "config/GlutenConfig.h"
#include "config/VeloxConfig.h"
@@ -1566,17 +1567,20 @@ core::PlanNodePtr
SubstraitToVeloxPlanConverter::toVeloxPlan(const ::substrait::
std::vector<std::string> colNameList;
std::vector<TypePtr> veloxTypeList;
std::vector<ColumnType> columnTypes;
- // Convert field names into lower case when not case-sensitive.
+ // Convert field names into lower case when not case-sensitive. Every name
that has to match a
+ // scan column (schema names, required-subfield column and field names) goes
through foldCase.
bool asLowerCase = !veloxCfg_->get<bool>(kCaseSensitive, false);
+ auto foldCase = [asLowerCase](std::string name) {
+ if (asLowerCase) {
+ folly::toLowerAscii(name);
+ }
+ return name;
+ };
if (readRel.has_base_schema()) {
const auto& baseSchema = readRel.base_schema();
colNameList.reserve(baseSchema.names().size());
for (const auto& name : baseSchema.names()) {
- std::string fieldName = name;
- if (asLowerCase) {
- folly::toLowerAscii(fieldName);
- }
- colNameList.emplace_back(fieldName);
+ colNameList.emplace_back(foldCase(name));
}
veloxTypeList = SubstraitParser::parseNamedStruct(baseSchema, asLowerCase);
SubstraitParser::parseColumnTypes(baseSchema, columnTypes);
@@ -1632,6 +1636,52 @@ core::PlanNodePtr
SubstraitToVeloxPlanConverter::toVeloxPlan(const ::substrait::
tableHandle = std::make_shared<connector::hive::HiveTableHandle>(
connectorId, "hive_table", std::move(subfieldFilters), remainingFilter,
dataColumns);
+ // Required subfields of scan columns (map-key pruning), from the
RequiredSubfieldsExtension the
+ // ScanMapKeyPruning rule packs into the ReadRel advanced extension. Each
path is transported as
+ // structured elements and rebuilt as a common::Subfield here, so no path
syntax is parsed.
+ std::unordered_map<std::string, std::vector<common::Subfield>>
requiredSubfieldsByCol;
+ if (readRel.has_advanced_extension() &&
readRel.advanced_extension().has_enhancement()) {
+ const auto& enhancement = readRel.advanced_extension().enhancement();
+ if (enhancement.Is<gluten::RequiredSubfieldsExtension>()) {
+ gluten::RequiredSubfieldsExtension extension;
+ VELOX_USER_CHECK(enhancement.UnpackTo(&extension), "Failed to unpack
RequiredSubfieldsExtension");
+ for (const auto& column : extension.columns()) {
+ auto columnName = foldCase(column.column());
+ VELOX_USER_CHECK(!columnName.empty(), "Required subfields with an
empty column name");
+ std::vector<common::Subfield> subfields;
+ for (const auto& subfield : column.subfields()) {
+ std::vector<std::unique_ptr<common::Subfield::PathElement>> elements;
+
elements.push_back(std::make_unique<common::Subfield::NestedField>(columnName));
+ for (const auto& element : subfield.elements()) {
+ using PathElement =
gluten::RequiredSubfieldsExtension::PathElement;
+ switch (element.element_case()) {
+ case PathElement::kField: {
+ // Same folding as the schema's field names, or the field
would resolve to nothing
+ // and read as null.
+ auto fieldName = foldCase(element.field());
+ VELOX_USER_CHECK(!fieldName.empty(), "Required subfield with
an empty field name");
+
elements.push_back(std::make_unique<common::Subfield::NestedField>(fieldName));
+ break;
+ }
+ case PathElement::kStringKey:
+
elements.push_back(std::make_unique<common::Subfield::StringSubscript>(element.string_key()));
+ break;
+ case PathElement::kLongKey:
+
elements.push_back(std::make_unique<common::Subfield::LongSubscript>(element.long_key()));
+ break;
+ default:
+ VELOX_USER_FAIL("Required subfield of column {} has an element
without a value", columnName);
+ }
+ }
+ subfields.emplace_back(std::move(elements));
+ }
+ if (!subfields.empty()) {
+ requiredSubfieldsByCol[columnName] = std::move(subfields);
+ }
+ }
+ }
+ }
+
// Get assignments and out names.
std::vector<std::string> outNames;
outNames.reserve(colNameList.size());
@@ -1647,9 +1697,7 @@ core::PlanNodePtr
SubstraitToVeloxPlanConverter::toVeloxPlan(const ::substrait::
icebergColumn = &columnIt->second;
} else if (asLowerCase) {
for (const auto& [name, column] : icebergSplitInfo->columns) {
- auto normalizedName = name;
- folly::toLowerAscii(normalizedName);
- if (normalizedName == colNameList[idx]) {
+ if (foldCase(name) == colNameList[idx]) {
icebergColumn = &column;
break;
}
@@ -1660,6 +1708,8 @@ core::PlanNodePtr
SubstraitToVeloxPlanConverter::toVeloxPlan(const ::substrait::
// handles only for regular columns so all data columns are mapped by field
// ID together, while partition columns keep the existing Hive conversion.
if (icebergColumn && columnType == ColumnType::kRegular) {
+ // Iceberg scans do not declare required subfields (the Scala side does
not prune them); a
+ // declaration reaching this branch is left unused and reported below.
assignments[outName] =
std::make_shared<connector::hive::iceberg::IcebergColumnHandle>(
colNameList[idx],
columnType,
@@ -1668,11 +1718,27 @@ core::PlanNodePtr
SubstraitToVeloxPlanConverter::toVeloxPlan(const ::substrait::
std::vector<common::Subfield>{},
icebergColumn->initialDefault);
} else {
+ std::vector<common::Subfield> requiredSubfields;
+ if (columnType == ColumnType::kRegular &&
!requiredSubfieldsByCol.empty()) {
+ auto it = requiredSubfieldsByCol.find(colNameList[idx]);
+ if (it != requiredSubfieldsByCol.end()) {
+ VLOG(1) << "Map-key pruning: applying " << it->second.size() << "
required subfields to column "
+ << colNameList[idx];
+ requiredSubfields = std::move(it->second);
+ requiredSubfieldsByCol.erase(it);
+ }
+ }
assignments[outName] =
std::make_shared<connector::hive::HiveColumnHandle>(
- colNameList[idx], columnType, veloxTypeList[idx],
veloxTypeList[idx]);
+ colNameList[idx], columnType, veloxTypeList[idx],
veloxTypeList[idx], std::move(requiredSubfields));
}
outNames.emplace_back(outName);
}
+ // A declared column that matched no regular scan column is not applied; the
column is then read
+ // whole, which is correct but not what the plan asked for.
+ for (const auto& [columnName, subfields] : requiredSubfieldsByCol) {
+ LOG(WARNING) << "Map-key pruning: " << subfields.size() << " required
subfields declared for column '" << columnName
+ << "' matched no scan column and were ignored";
+ }
auto outputType = ROW(std::move(outNames), std::move(veloxTypeList));
if (readRel.has_virtual_table()) {
diff --git a/cpp/velox/tests/SparkFunctionTest.cc
b/cpp/velox/tests/SparkFunctionTest.cc
index ceb979a5ed..0384a69bee 100644
--- a/cpp/velox/tests/SparkFunctionTest.cc
+++ b/cpp/velox/tests/SparkFunctionTest.cc
@@ -144,3 +144,36 @@ TEST_F(SparkFunctionTest,
expressionLevelLegacyCastIgnoresSessionAnsiOn) {
facebook::velox::test::assertEqualVectors(makeFlatVector<int8_t>({-121}),
evaluate(legacyCast, input));
}
+
+// Spark GetMapValue is emitted as get_map_value and registered through the
+// function overlay as a map-only, pushdown-capable subscript, so that
+// Expr::extractSubfields() yields m["k"] instead of the bare column path. This
+// keeps map-key pruning declared via HiveColumnHandle::requiredSubfields
+// effective when the remaining filter references the map.
+TEST_F(SparkFunctionTest, getMapValueSubfieldExtraction) {
+ // Spark function names throughout: the test base registers only the Spark
+ // functions, so the parser's `>` (gt) does not resolve.
+ auto mapRow = ROW({"m"}, {MAP(VARCHAR(), BIGINT())});
+ auto exprSet = compileExpression("greaterthan(get_map_value(m, 'k'), 0)",
mapRow);
+ auto subfields = exprSet->expr(0)->extractSubfields();
+ ASSERT_EQ(subfields.size(), 1);
+ EXPECT_EQ(subfields[0].toString(), "m[\"k\"]");
+
+ // Velox's element_at stays non-pushdown: it also serves arrays, and a
+ // Subfield path cannot express a negative array index.
+ exprSet = compileExpression("greaterthan(element_at(m, 'k'), 0)", mapRow);
+ subfields = exprSet->expr(0)->extractSubfields();
+ ASSERT_EQ(subfields.size(), 1);
+ EXPECT_EQ(subfields[0].toString(), "m");
+}
+
+TEST_F(SparkFunctionTest, getMapValueSemantics) {
+ auto maps = makeMapVector<std::string, int64_t>({{{"k", 1}}, {{"x", 2}},
{}});
+ auto result = evaluate("get_map_value(c0, 'k')", makeRowVector({maps}));
+
facebook::velox::test::assertEqualVectors(makeNullableFlatVector<int64_t>({1,
std::nullopt, std::nullopt}), result);
+
+ // Integer literals parse as BIGINT, and the key must match the map's key
type exactly.
+ auto intKeyed = makeMapVector<int64_t, std::string>({{{1, "a"}, {2, "b"}},
{{3, "c"}}});
+ result = evaluate("get_map_value(c0, 2)", makeRowVector({intKeyed}));
+
facebook::velox::test::assertEqualVectors(makeNullableFlatVector<std::string>({"b",
std::nullopt}), result);
+}
diff --git a/docs/velox-configuration.md b/docs/velox-configuration.md
index 86b1b116eb..6cc3995645 100644
--- a/docs/velox-configuration.md
+++ b/docs/velox-configuration.md
@@ -79,6 +79,7 @@ nav_order: 16
|
spark.gluten.sql.columnar.backend.velox.resizeBatches.shuffleInputOutput.minSize
| 🔄 Dynamic | <undefined> | The minimum batch size for shuffle input
and output. If size of an input batch is smaller than the value, it will be
combined with other batches before sending to shuffle. The same applies for
batches output by shuffle read. Only functions when
spark.gluten.sql.columnar.backend.velox.resizeBatches.shuffleInput or
spark.gluten.sql.columnar.backend.velox.resizeBatches.shu [...]
| spark.gluten.sql.columnar.backend.velox.resizeBatches.shuffleOutput
| 🔄 Dynamic | false | If true, combine small columnar
batches together right after shuffle read. The default minimum output batch
size is equal to 0.25 * spark.gluten.sql.columnar.maxBatchSize
[...]
| spark.gluten.sql.columnar.backend.velox.scan.bloomFilterPushdown.enabled
| âš“ Static | false | Whether to push Bloom filters into
Velox scans.
[...]
+| spark.gluten.sql.columnar.backend.velox.scanMapKeyPruningEnabled
| 🔄 Dynamic | false | When true, a map column read by a
native Parquet scan is declared to Velox with only the constant keys the query
looks up (m['k'], element_at(m, 'k'), null checks), so the reader skips the
other entries. Applies when every use of the map sits in the filters and
projections directly above the scan and the map is dropped before any other
operator sees it; otherwise the ma [...]
| spark.gluten.sql.columnar.backend.velox.showTaskMetricsWhenFinished
| 🔄 Dynamic | false | Show velox full task metrics when
finished.
[...]
| spark.gluten.sql.columnar.backend.velox.spillFileSystem
| 🔄 Dynamic | local | The filesystem used to store spill
data. local: The local file system. heap-over-local: Write file to JVM heap if
having extra heap space. Otherwise write to local file system.
[...]
| spark.gluten.sql.columnar.backend.velox.spillNumMaxMergeFiles
| 🔄 Dynamic | 0 | The max number of files to merge at a
time when merging sorted files into a single ordered stream. 0 means unlimited.
[...]
diff --git
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
index fe26accb71..6165b70439 100644
---
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
+++
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
@@ -49,7 +49,8 @@ case class DeltaScanTransformer(
override val dataFilters: Seq[Expression],
override val tableIdentifier: Option[TableIdentifier],
override val disableBucketedScan: Boolean = false,
- override val pushDownFilters: Option[Seq[Expression]] = None)
+ override val pushDownFilters: Option[Seq[Expression]] = None,
+ override val requiredMapSubfields: Map[String, Seq[SubfieldPath]] =
Map.empty)
extends FileSourceScanExecTransformerBase(
relation,
stream,
@@ -257,12 +258,25 @@ case class DeltaScanTransformer(
QueryPlan.normalizePredicates(dataFilters, output),
None,
disableBucketedScan,
- pushDownFilters.map(QueryPlan.normalizePredicates(_, output))
+ pushDownFilters.map(QueryPlan.normalizePredicates(_, output)),
+ requiredMapSubfields
)
}
override def withNewPushdownFilters(filters: Seq[Expression]):
BasicScanExecTransformer =
copy(pushDownFilters = Some(filters))
+
+ // Under name or id column mapping the scan reads physical `col-<uuid>`
names, which the rule
+ // renders from the logical schema; the paths would not match the file. Only
a Delta Parquet
+ // format without column mapping prunes; any other format cannot be checked
and is not pruned.
+ override def supportsMapKeyPruning: Boolean = relation.fileFormat match {
+ case d: DeltaParquetFileFormat => d.columnMappingMode == NoMapping
+ case _ => false
+ }
+
+ override def withRequiredMapSubfields(
+ subfields: Map[String, Seq[SubfieldPath]]): BasicScanExecTransformer =
+ copy(requiredMapSubfields = subfields)
}
object DeltaScanTransformer {
diff --git
a/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
b/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
index 87915b7b77..26caffcefe 100644
--- a/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
+++ b/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
@@ -632,7 +632,7 @@ abstract class DeltaSuite extends
WholeStageTransformerSuite {
}
}
- test("deletion vector on partitioned table") {
+ testWithMinSparkVersion("deletion vector on partitioned table", "3.4") {
withTempPath {
p =>
import testImplicits._
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/SparkPlanExecApi.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/SparkPlanExecApi.scala
index 916c31c2ed..0141320030 100644
---
a/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/SparkPlanExecApi.scala
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/SparkPlanExecApi.scala
@@ -175,6 +175,17 @@ trait SparkPlanExecApi {
right: ExpressionTransformer,
original: GetMapValue): ExpressionTransformer
+ /**
+ * Generate an expression transformer to transform ElementAt to Substrait.
The default emits the
+ * mapped function as is; a backend may pick another native function for
some inputs.
+ */
+ def genElementAtTransformer(
+ substraitExprName: String,
+ left: ExpressionTransformer,
+ right: ExpressionTransformer,
+ original: ElementAt): ExpressionTransformer =
+ GenericExpressionTransformer(substraitExprName, Seq(left, right), original)
+
def genStringToMapTransformer(
substraitExprName: String,
children: Seq[ExpressionTransformer],
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/TransformerApi.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/TransformerApi.scala
index c35a85398a..98330347a3 100644
---
a/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/TransformerApi.scala
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/backendsapi/TransformerApi.scala
@@ -16,7 +16,7 @@
*/
package org.apache.gluten.backendsapi
-import org.apache.gluten.execution.WriteFilesExecTransformer
+import org.apache.gluten.execution.{SubfieldPath, WriteFilesExecTransformer}
import org.apache.gluten.substrait.SubstraitContext
import org.apache.gluten.substrait.expression.ExpressionNode
@@ -81,6 +81,14 @@ trait TransformerApi {
throw new UnsupportedOperationException("Iceberg initial-default reads are
not supported")
}
+ /**
+ * Packs the required subfields of scan columns (map-key pruning) into a
backend-specific read
+ * extension; see BasicScanExecTransformer.requiredMapSubfields.
+ */
+ def packRequiredSubfields(subfields: Map[String, Seq[SubfieldPath]]): Any = {
+ throw new UnsupportedOperationException("Map-key pruning is not supported")
+ }
+
/** This method is only used for CH backend tests */
def invalidateSQLExecutionResource(executionId: String): Unit = {}
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/BasicScanExecTransformer.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/BasicScanExecTransformer.scala
index c765ed333d..faa19881e3 100644
---
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/BasicScanExecTransformer.scala
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/BasicScanExecTransformer.scala
@@ -79,6 +79,24 @@ trait BasicScanExecTransformer extends LeafTransformSupport
with BaseDataSource
s"${getClass.toString} does not support push down filters.")
}
+ /**
+ * Required subfields per top-level column, set by the backend map-key
pruning rule. Key is the
+ * normalized column name; values are the paths the query accesses under it,
for example
+ * `c.m["k1"].f1`. Serialized into the ReadRel advanced extension so the
native reader keeps only
+ * the map entries those paths name.
+ */
+ def requiredMapSubfields: Map[String, Seq[SubfieldPath]] = Map.empty
+
+ /** Whether this scan supports map-key pruning via required subfields. */
+ def supportsMapKeyPruning: Boolean = false
+
+ /** Copy the scan with required subfield paths for map-key pruning. */
+ def withRequiredMapSubfields(
+ subfields: Map[String, Seq[SubfieldPath]]): BasicScanExecTransformer = {
+ throw new UnsupportedOperationException(
+ s"${getClass.toString} does not support map-key pruning.")
+ }
+
def getMetadataColumns(): Seq[AttributeReference]
/** This can be used to report FileFormat for a file based scan operator. */
@@ -188,7 +206,12 @@ trait BasicScanExecTransformer extends
LeafTransformSupport with BaseDataSource
val optimization =
BackendsApiManager.getTransformerApiInstance.packPBMessage(
StringValue.newBuilder.setValue(s"isMergeTree=$mergeTreeFlag\n").build)
- val extensionNode = ExtensionBuilder.makeAdvancedExtension(optimization,
null)
+ val enhancement = if (requiredMapSubfields.isEmpty) {
+ null
+ } else {
+
BackendsApiManager.getTransformerApiInstance.packRequiredSubfields(requiredMapSubfields)
+ }
+ val extensionNode = ExtensionBuilder.makeAdvancedExtension(optimization,
enhancement)
val readNode = RelBuilder.makeReadRel(
typeNodes,
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/BatchScanExecTransformer.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/BatchScanExecTransformer.scala
index a50b252043..c95237c7ed 100644
---
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/BatchScanExecTransformer.scala
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/BatchScanExecTransformer.scala
@@ -48,7 +48,8 @@ case class BatchScanExecTransformer(
override val commonPartitionValues: Option[Seq[(InternalRow, Int)]] = None,
override val applyPartialClustering: Boolean = false,
override val replicatePartitions: Boolean = false,
- override val pushDownFilters: Option[Seq[Expression]] = None)
+ override val pushDownFilters: Option[Seq[Expression]] = None,
+ override val requiredMapSubfields: Map[String, Seq[SubfieldPath]] =
Map.empty)
extends BatchScanExecTransformerBase(
output,
scan,
@@ -80,6 +81,13 @@ case class BatchScanExecTransformer(
this.copy(pushDownFilters = Some(filters))
}
+ override def supportsMapKeyPruning: Boolean = true
+
+ override def withRequiredMapSubfields(
+ subfields: Map[String, Seq[SubfieldPath]]): BatchScanExecTransformerBase
= {
+ this.copy(requiredMapSubfields = subfields)
+ }
+
override def withOutput(newOutput: Seq[AttributeReference]):
BatchScanExecTransformerBase = {
this.copy(output = newOutput)
}
@@ -216,13 +224,19 @@ abstract class BatchScanExecTransformerBase(
case other: BatchScanExecTransformerBase =>
this.keyGroupedPartitioning == other.keyGroupedPartitioning &&
this.pushDownFilters == other.pushDownFilters &&
+ this.requiredMapSubfields == other.requiredMapSubfields &&
super.equals(other)
case _ =>
false
}
override def hashCode(): Int =
- Objects.hashCode(batch, runtimeFilters, keyGroupedPartitioning,
pushDownFilters)
+ Objects.hashCode(
+ batch,
+ runtimeFilters,
+ keyGroupedPartitioning,
+ pushDownFilters,
+ requiredMapSubfields)
/** Return a copy of this scan with a new output schema. */
def withOutput(newOutput: Seq[AttributeReference]):
BatchScanExecTransformerBase
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
index 088c5f2d6d..ba8e66b076 100644
---
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/FileSourceScanExecTransformer.scala
@@ -50,7 +50,8 @@ case class FileSourceScanExecTransformer(
override val tableIdentifier: Option[TableIdentifier],
override val disableBucketedScan: Boolean = false,
override val pushDownFilters: Option[Seq[Expression]] = None,
- inputStats: Option[InputStats] = None)
+ inputStats: Option[InputStats] = None,
+ override val requiredMapSubfields: Map[String, Seq[SubfieldPath]] =
Map.empty)
extends FileSourceScanExecTransformerBase(
relation,
stream,
@@ -85,12 +86,19 @@ case class FileSourceScanExecTransformer(
None,
disableBucketedScan,
pushDownFilters.map(QueryPlan.normalizePredicates(_, output)),
- inputStats
+ inputStats,
+ requiredMapSubfields
)
}
override def withNewPushdownFilters(filters: Seq[Expression]):
FileSourceScanExecTransformer =
copy(pushDownFilters = Some(filters))
+
+ override def supportsMapKeyPruning: Boolean = true
+
+ override def withRequiredMapSubfields(
+ subfields: Map[String, Seq[SubfieldPath]]):
FileSourceScanExecTransformer =
+ copy(requiredMapSubfields = subfields)
}
abstract class FileSourceScanExecTransformerBase(
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/SubfieldPath.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/SubfieldPath.scala
new file mode 100644
index 0000000000..6515bd1ef7
--- /dev/null
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/SubfieldPath.scala
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.gluten.execution
+
+/** One step of a [[SubfieldPath]] below its column. */
+sealed trait SubfieldElement
+
+object SubfieldElement {
+
+ /** A struct field, by the schema's name. */
+ case class Field(name: String) extends SubfieldElement
+
+ /** A map lookup by a string key. */
+ case class StringKey(key: String) extends SubfieldElement
+
+ /** A map lookup by an integral key. */
+ case class LongKey(key: Long) extends SubfieldElement
+}
+
+/**
+ * A path from a scan output column into its value, e.g. column `m` with
elements [StringKey("a"),
+ * Field("t")] for `m['a'].t`. Declared on a scan as a required subfield so
the native reader keeps
+ * only the map entries such paths name. Transported structurally (see
+ * RequiredSubfieldsExtension.proto); toString renders the Velox Subfield
syntax for logs and tests.
+ */
+case class SubfieldPath(column: String, elements: Seq[SubfieldElement]) {
+ override def toString: String = {
+ val sb = new StringBuilder(column)
+ elements.foreach {
+ case SubfieldElement.Field(name) => sb.append('.').append(name)
+ case SubfieldElement.StringKey(key) =>
+ sb.append("[\"").append(key.replace("\\", "\\\\").replace("\"",
"\\\"")).append("\"]")
+ case SubfieldElement.LongKey(key) =>
sb.append('[').append(key).append(']')
+ }
+ sb.toString
+ }
+}
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/expression/ExpressionConverter.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/expression/ExpressionConverter.scala
index ff8acfb8a2..97e21a375b 100644
---
a/gluten-substrait/src/main/scala/org/apache/gluten/expression/ExpressionConverter.scala
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/expression/ExpressionConverter.scala
@@ -319,6 +319,13 @@ object ExpressionConverter extends SQLConfHelper with
Logging {
val children =
c.children.map(replaceWithExpressionTransformer0(_, attributeSeq,
expressionsMap))
CreateMapTransformer(substraitExprName, children, c)
+ case ea: ElementAt =>
+ BackendsApiManager.getSparkPlanExecApiInstance.genElementAtTransformer(
+ substraitExprName,
+ replaceWithExpressionTransformer0(ea.left, attributeSeq,
expressionsMap),
+ replaceWithExpressionTransformer0(ea.right, attributeSeq,
expressionsMap),
+ ea
+ )
case g: GetMapValue =>
BackendsApiManager.getSparkPlanExecApiInstance.genGetMapValueTransformer(
substraitExprName,
diff --git
a/gluten-substrait/src/test/scala/org/apache/spark/sql/GlutenQueryTest.scala
b/gluten-substrait/src/test/scala/org/apache/spark/sql/GlutenQueryTest.scala
index 1b541b3267..8d726359ce 100644
--- a/gluten-substrait/src/test/scala/org/apache/spark/sql/GlutenQueryTest.scala
+++ b/gluten-substrait/src/test/scala/org/apache/spark/sql/GlutenQueryTest.scala
@@ -30,7 +30,7 @@ import org.apache.spark.sql.catalyst.plans.logical._
import org.apache.spark.sql.catalyst.util._
import org.apache.spark.sql.classic.ClassicConversions._
import org.apache.spark.sql.execution.{CommandResultExec, SparkPlan,
SQLExecution, UnaryExecNode, WholeStageCodegenExec}
-import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
ShuffleQueryStageExec}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
BroadcastQueryStageExec, ShuffleQueryStageExec}
import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
import org.apache.spark.sql.execution.columnar.InMemoryRelation
import org.apache.spark.storage.StorageLevel
@@ -345,6 +345,8 @@ abstract class GlutenQueryTest extends QueryTest with
AdaptiveSparkPlanHelper {
getExecutedPlan(cmd.commandPhysicalPlan)
case s: ShuffleQueryStageExec =>
getExecutedPlan(s.plan)
+ case b: BroadcastQueryStageExec =>
+ getExecutedPlan(b.plan)
case plan =>
plan.children.flatMap(getExecutedPlan)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]