This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 3df58d5280 perf: intern QueryContext SQL text into a per-plan pool (up
to 20x smaller serialized plans for TPC-DS) (#5204)
3df58d5280 is described below
commit 3df58d52808fc125b2f6d132a712ed9745b9f022
Author: Andy Grove <[email protected]>
AuthorDate: Sun Aug 2 07:32:29 2026 -0600
perf: intern QueryContext SQL text into a per-plan pool (up to 20x smaller
serialized plans for TPC-DS) (#5204)
---
.github/workflows/pr_build_linux.yml | 1 +
.github/workflows/pr_build_macos.yml | 1 +
native/common/src/query_context.rs | 7 +-
native/core/src/execution/jni_api.rs | 1 +
native/core/src/execution/planner.rs | 128 +++++++++++-----
native/proto/src/proto/expr.proto | 11 +-
native/proto/src/proto/operator.proto | 10 ++
.../apache/comet/serde/QueryContextInterner.scala | 137 +++++++++++++++++
.../spark/sql/comet/CometNativeScanExec.scala | 11 +-
.../org/apache/spark/sql/comet/operators.scala | 37 +++--
.../apache/comet/QueryContextInternerSuite.scala | 163 +++++++++++++++++++++
11 files changed, 443 insertions(+), 64 deletions(-)
diff --git a/.github/workflows/pr_build_linux.yml
b/.github/workflows/pr_build_linux.yml
index ff69a2cc68..2d01ecc104 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -343,6 +343,7 @@ jobs:
org.apache.comet.CometNativeSuite
org.apache.comet.CometConfSuite
org.apache.comet.CometPublicApiSuite
+ org.apache.comet.QueryContextInternerSuite
org.apache.comet.CometSetOpWithGroupBySuite
org.apache.comet.CometSparkSessionExtensionsSuite
org.apache.spark.CometPluginsSuite
diff --git a/.github/workflows/pr_build_macos.yml
b/.github/workflows/pr_build_macos.yml
index f3f3f08269..5f60c07451 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -159,6 +159,7 @@ jobs:
org.apache.comet.CometNativeSuite
org.apache.comet.CometConfSuite
org.apache.comet.CometPublicApiSuite
+ org.apache.comet.QueryContextInternerSuite
org.apache.comet.CometSetOpWithGroupBySuite
org.apache.comet.CometSparkSessionExtensionsSuite
org.apache.spark.CometPluginsSuite
diff --git a/native/common/src/query_context.rs
b/native/common/src/query_context.rs
index 10ecef6550..405ce88d04 100644
--- a/native/common/src/query_context.rs
+++ b/native/common/src/query_context.rs
@@ -59,9 +59,12 @@ pub struct QueryContext {
}
impl QueryContext {
+ /// `sql_text` accepts either an owned `String` or an already-shared
`Arc<String>`. The plan
+ /// deserializer passes an `Arc` cloned from the plan's SQL text pool so
that all the contexts
+ /// in one plan share a single allocation instead of each cloning the full
query text.
#[allow(clippy::too_many_arguments)]
pub fn new(
- sql_text: String,
+ sql_text: impl Into<Arc<String>>,
start_index: i32,
stop_index: i32,
object_type: Option<String>,
@@ -70,7 +73,7 @@ impl QueryContext {
start_position: i32,
) -> Self {
Self {
- sql_text: Arc::new(sql_text),
+ sql_text: sql_text.into(),
start_index,
stop_index,
object_type,
diff --git a/native/core/src/execution/jni_api.rs
b/native/core/src/execution/jni_api.rs
index 09e5cf6cd5..f798b80335 100644
--- a/native/core/src/execution/jni_api.rs
+++ b/native/core/src/execution/jni_api.rs
@@ -781,6 +781,7 @@ pub unsafe extern "system" fn
Java_org_apache_comet_Native_executePlan(
let planner =
PhysicalPlanner::new(Arc::clone(&exec_context.session_ctx), partition)
.with_exec_id(exec_context_id)
+ .with_sql_text_pool(&exec_context.spark_plan)
.with_task_context(exec_context.task_context.clone());
let (scans, shuffle_scans, root_op) = planner.create_plan(
&exec_context.spark_plan,
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index 5accd31609..f20dadf7f3 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -137,6 +137,7 @@ use datafusion_comet_spark_expr::{
};
use itertools::Itertools;
use jni::objects::{Global, JObject};
+use log::warn;
use num::{BigInt, ToPrimitive};
use object_store::path::Path;
use std::cmp::max;
@@ -240,6 +241,11 @@ pub struct PhysicalPlanner {
partition: i32,
session_ctx: Arc<SessionContext>,
query_context_registry: Arc<datafusion_comet_spark_expr::QueryContextMap>,
+ /// SQL texts referenced by `QueryContext.sql_text_idx`, taken from the
root operator's
+ /// `sql_text_pool`. Held as `Arc`s so that every context in the plan
shares one allocation
+ /// rather than each cloning the full query text out of the proto. Empty
when the plan was
+ /// serialized without interning, in which case contexts carry `sql_text`
inline.
+ sql_text_pool: Vec<Arc<String>>,
/// Captured at `createPlan` time on `ExecutionContext`; see that struct
for the
/// propagation rationale. `None` when no driving Spark task is available.
task_context: Option<Arc<Global<JObject<'static>>>>,
@@ -258,10 +264,81 @@ impl PhysicalPlanner {
session_ctx,
partition,
query_context_registry:
datafusion_comet_spark_expr::create_query_context_map(),
+ sql_text_pool: vec![],
task_context: None,
}
}
+ /// Load the SQL text pool from the root operator of the plan about to be
planned. Must be
+ /// called with the *root* operator: the JVM only populates the pool
there, and
+ /// `QueryContext.sql_text_idx` values are indices into it.
+ pub fn with_sql_text_pool(mut self, root: &Operator) -> Self {
+ self.sql_text_pool = root
+ .sql_text_pool
+ .iter()
+ .map(|text| Arc::new(text.clone()))
+ .collect();
+ self
+ }
+
+ /// Register the `QueryContext` carried by a serialized expression, if
any, so that native ANSI
+ /// errors raised by it can render Spark's `== SQL ... ==` block. Shared
by `create_expr` and
+ /// `create_agg_expr`.
+ fn register_query_context(
+ &self,
+ expr_id: Option<u64>,
+ ctx_proto: Option<&spark_expression::QueryContext>,
+ ) {
+ if let (Some(expr_id), Some(ctx_proto)) = (expr_id, ctx_proto) {
+ self.query_context_registry
+ .register(expr_id, self.build_query_context(ctx_proto));
+ }
+ }
+
+ /// Resolve a serialized `QueryContext` into a `QueryContext`, sharing the
pooled SQL text
+ /// when the context refers to the pool by index. Falls back to the inline
`sql_text` for
+ /// plans serialized without interning -- the paths that serialize an
operator directly rather
+ /// than through `convertBlock` (e.g. `CometNativeWriteExec`, the native
shuffle writer) and
+ /// the bare-`Expr` Parquet filter path, which has no root operator to
hang a pool on.
+ fn build_query_context(
+ &self,
+ ctx_proto: &spark_expression::QueryContext,
+ ) -> datafusion_comet_spark_expr::QueryContext {
+ let pooled = ctx_proto
+ .sql_text_idx
+ .map(|idx| match usize::try_from(idx).ok() {
+ Some(idx) => self.sql_text_pool.get(idx).map(Arc::clone),
+ None => None,
+ });
+
+ let sql_text: Arc<String> = match pooled {
+ Some(Some(text)) => text,
+ // An index we cannot resolve means the plan was interned against
a pool this planner
+ // never saw, so `sql_text` is empty and the error block would
silently come out blank.
+ // Warn rather than fail: a degraded error message is better than
a failed query.
+ Some(None) => {
+ warn!(
+ "QueryContext sql_text_idx {:?} is out of range for a SQL
text pool of {} \
+ entries; SQL context will be missing from any error
raised by this expression",
+ ctx_proto.sql_text_idx,
+ self.sql_text_pool.len()
+ );
+ Arc::new(ctx_proto.sql_text.clone())
+ }
+ None => Arc::new(ctx_proto.sql_text.clone()),
+ };
+
+ datafusion_comet_spark_expr::QueryContext::new(
+ sql_text,
+ ctx_proto.start_index,
+ ctx_proto.stop_index,
+ ctx_proto.object_type.clone(),
+ ctx_proto.object_name.clone(),
+ ctx_proto.line,
+ ctx_proto.start_position,
+ )
+ }
+
pub fn with_exec_id(mut self, exec_context_id: i64) -> Self {
self.exec_context_id = exec_context_id;
self
@@ -345,25 +422,7 @@ impl PhysicalPlanner {
spark_expr: &Expr,
input_schema: SchemaRef,
) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> {
- // Register QueryContext if present
- if let (Some(expr_id), Some(ctx_proto)) =
- (spark_expr.expr_id, spark_expr.query_context.as_ref())
- {
- // Deserialize QueryContext from protobuf
- let query_ctx = datafusion_comet_spark_expr::QueryContext::new(
- ctx_proto.sql_text.clone(),
- ctx_proto.start_index,
- ctx_proto.stop_index,
- ctx_proto.object_type.clone(),
- ctx_proto.object_name.clone(),
- ctx_proto.line,
- ctx_proto.start_position,
- );
-
- // Register query context for error reporting
- let registry = &self.query_context_registry;
- registry.register(expr_id, query_ctx);
- }
+ self.register_query_context(spark_expr.expr_id,
spark_expr.query_context.as_ref());
// Try to use the modular registry first - this automatically handles
any registered expression types
if ExpressionRegistry::global().can_handle(spark_expr) {
@@ -2391,25 +2450,7 @@ impl PhysicalPlanner {
spark_expr: &AggExpr,
schema: SchemaRef,
) -> Result<AggregateFunctionExpr, ExecutionError> {
- // Register QueryContext if present
- if let (Some(expr_id), Some(ctx_proto)) =
- (spark_expr.expr_id, spark_expr.query_context.as_ref())
- {
- // Deserialize QueryContext from protobuf
- let query_ctx = datafusion_comet_spark_expr::QueryContext::new(
- ctx_proto.sql_text.clone(),
- ctx_proto.start_index,
- ctx_proto.stop_index,
- ctx_proto.object_type.clone(),
- ctx_proto.object_name.clone(),
- ctx_proto.line,
- ctx_proto.start_position,
- );
-
- // Register query context for error reporting
- let registry = &self.query_context_registry;
- registry.register(expr_id, query_ctx);
- }
+ self.register_query_context(spark_expr.expr_id,
spark_expr.query_context.as_ref());
match spark_expr.expr_struct.as_ref().unwrap() {
AggExprStruct::Count(expr) => {
@@ -4481,6 +4522,7 @@ mod tests {
fn test_unpack_dictionary_primitive() {
let op_scan = Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![],
op_struct: Some(OpStruct::Scan(spark_operator::Scan {
fields: vec![spark_expression::DataType {
@@ -4546,6 +4588,7 @@ mod tests {
fn test_unpack_dictionary_string() {
let op_scan = Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![],
op_struct: Some(OpStruct::Scan(spark_operator::Scan {
fields: vec![spark_expression::DataType {
@@ -4696,6 +4739,7 @@ mod tests {
Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![child_op],
op_struct: Some(OpStruct::Filter(spark_operator::Filter {
predicate: Some(expr),
@@ -4722,6 +4766,7 @@ mod tests {
let op_scan = create_scan();
let op_join = Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![op_scan.clone(), op_scan.clone()],
op_struct: Some(OpStruct::HashJoin(spark_operator::HashJoin {
left_join_keys: vec![create_bound_reference(0)],
@@ -4758,6 +4803,7 @@ mod tests {
fn create_scan() -> Operator {
Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![],
op_struct: Some(OpStruct::Scan(spark_operator::Scan {
fields: vec![create_proto_datatype()],
@@ -4787,6 +4833,7 @@ mod tests {
// ScanExec: source=[CometScan parquet (unknown)], schema=[col_0:
Int32]
let op_scan = Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![],
op_struct: Some(OpStruct::Scan(spark_operator::Scan {
fields: vec![
@@ -4834,6 +4881,7 @@ mod tests {
let projection = Operator {
children: vec![op_scan],
plan_id: 0,
+ sql_text_pool: vec![],
op_struct: Some(OpStruct::Projection(spark_operator::Projection {
project_list: vec![spark_expression::Expr {
expr_struct:
Some(ExprStruct::ScalarFunc(spark_expression::ScalarFunc {
@@ -4908,6 +4956,7 @@ mod tests {
// Mock scan operator with 3 INT32 columns
let op_scan = Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![],
op_struct: Some(OpStruct::Scan(spark_operator::Scan {
fields: vec![
@@ -4958,6 +5007,7 @@ mod tests {
let projection = Operator {
children: vec![op_scan],
plan_id: 0,
+ sql_text_pool: vec![],
op_struct: Some(OpStruct::Projection(spark_operator::Projection {
project_list: vec![spark_expression::Expr {
expr_struct:
Some(ExprStruct::ScalarFunc(spark_expression::ScalarFunc {
@@ -5394,6 +5444,7 @@ mod tests {
// Create a Scan operator with Date32 (DATE) and Int8 (TINYINT) columns
let op_scan = Operator {
plan_id: 0,
+ sql_text_pool: vec![],
children: vec![],
op_struct: Some(OpStruct::Scan(spark_operator::Scan {
fields: vec![
@@ -5458,6 +5509,7 @@ mod tests {
let projection = Operator {
children: vec![op_scan],
plan_id: 1,
+ sql_text_pool: vec![],
op_struct: Some(OpStruct::Projection(spark_operator::Projection {
project_list: vec![subtract_expr],
})),
diff --git a/native/proto/src/proto/expr.proto
b/native/proto/src/proto/expr.proto
index 4c16186f57..04b20b5df3 100644
--- a/native/proto/src/proto/expr.proto
+++ b/native/proto/src/proto/expr.proto
@@ -109,7 +109,11 @@ message Expr {
// QueryContext provides SQL query context for error messages.
// Mirrors Spark's SQLQueryContext for rich error reporting.
message QueryContext {
- // Full SQL query text
+ // Full SQL query text. Left empty when `sql_text_idx` is set, which is the
case for a plan
+ // serialized through `CometNativeExec.convertBlock`. Still used by the
paths that serialize an
+ // operator directly without interning (e.g. `CometNativeWriteExec`, the
native shuffle writer,
+ // the Iceberg scan's `convertBlock` override) and by the bare-`Expr`
Parquet filter path, which
+ // has no root operator to carry a pool.
string sql_text = 1;
// Character offset where expression starts (0-based)
@@ -129,6 +133,11 @@ message QueryContext {
// Column position within the line (0-based)
int32 start_position = 7;
+
+ // Index into the root `Operator.sql_text_pool` holding this context's SQL
text. Set instead of
+ // `sql_text` so that a query text shared by many expressions is serialized
once per plan rather
+ // than once per expression.
+ optional int32 sql_text_idx = 8;
}
message AggExpr {
diff --git a/native/proto/src/proto/operator.proto
b/native/proto/src/proto/operator.proto
index d9515edc99..ced87262f3 100644
--- a/native/proto/src/proto/operator.proto
+++ b/native/proto/src/proto/operator.proto
@@ -36,6 +36,16 @@ message Operator {
// Spark plan ID
uint32 plan_id = 2;
+ // Pool of distinct SQL texts referenced by `QueryContext.sql_text_idx`.
Every expression that
+ // carries a QueryContext needs the full query text to render Spark's `==
SQL ... ==` error
+ // block, and in a typical plan they all want the *same* text -- embedding
it per expression made
+ // the query text over 95% of the serialized plan. The JVM interns the texts
into this pool on
+ // the root operator of each serialized native block (see
`QueryContextInterner`) and leaves
+ // `QueryContext.sql_text` empty.
+ //
+ // Only populated on the root operator; nested operators leave it empty.
+ repeated string sql_text_pool = 3;
+
oneof op_struct {
Scan scan = 100;
Projection projection = 101;
diff --git
a/spark/src/main/scala/org/apache/comet/serde/QueryContextInterner.scala
b/spark/src/main/scala/org/apache/comet/serde/QueryContextInterner.scala
new file mode 100644
index 0000000000..7d53be29f8
--- /dev/null
+++ b/spark/src/main/scala/org/apache/comet/serde/QueryContextInterner.scala
@@ -0,0 +1,137 @@
+/*
+ * 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.comet.serde
+
+import scala.collection.mutable
+
+import com.google.protobuf.Descriptors.FieldDescriptor
+import com.google.protobuf.Message
+
+import org.apache.comet.serde.OperatorOuterClass.Operator
+
+/**
+ * Deduplicates `QueryContext.sql_text` across a serialized native plan.
+ *
+ * Every expression that Comet converts carries a `QueryContext` so that
native ANSI errors can
+ * render Spark's `== SQL (line N, position M) ==` block, and that block
prints the *whole* query
+ * text -- so the text cannot be trimmed to a fragment. But in a typical plan
every expression
+ * carries the same text, and embedding it per expression made the query text
the overwhelming
+ * majority of the serialized plan: on a 778-character query with 90
expressions, 56 KB of a 58 KB
+ * plan (96%) was duplicated SQL text.
+ *
+ * This rewrites the tree so each distinct text appears once, in
`Operator.sql_text_pool` on the
+ * root operator, with each `QueryContext` referring to it by `sql_text_idx`.
The plan bytes are
+ * shipped in the stage's task binary, re-parsed and re-serialized per task by
+ * `CometExecRDD.compute` whenever the plan contains a native scan, and
decoded per task by the
+ * native planner, so the saving is paid back on every task.
+ *
+ * The walk is descriptor-driven rather than a hand-written recursion over
`Expr`'s ~75-way
+ * `oneof`: a `QueryContext` can sit on any `Expr` or `AggExpr` at any nesting
depth, and a
+ * hand-written walk would silently miss newly added expression variants.
+ * `QueryContextInternerSuite` asserts that no un-pooled context survives in a
real plan.
+ */
+object QueryContextInterner {
+
+ /**
+ * Returns `op` with all `QueryContext.sql_text` values hoisted into the
root's `sql_text_pool`,
+ * or `op` itself when there is nothing to intern.
+ */
+ def intern(op: Operator): Operator = {
+ // Defensive no-op so interning is idempotent. Not reachable from the
single call site in
+ // `CometNativeExec.convertBlock`, which always starts from the
un-interned `nativeOp`.
+ if (op.getSqlTextPoolCount > 0) return op
+
+ val builder = op.toBuilder
+ // Each text is appended to the pool the first time it is seen and its
position becomes the
+ // index every context referring to it carries, so the pool needs no
separate second pass.
+ val indexOf = mutable.Map.empty[String, Int]
+ walk(
+ builder,
+ ctx => {
+ val idx = indexOf.getOrElseUpdate(
+ ctx.getSqlText, {
+ builder.addSqlTextPool(ctx.getSqlText)
+ builder.getSqlTextPoolCount - 1
+ })
+ Some(ctx.toBuilder.clearSqlText().setSqlTextIdx(idx).build())
+ })
+ if (indexOf.isEmpty) {
+ // No expression carried a context (e.g. a DataFrame-API query, where
Spark records no SQL
+ // text). Return the original rather than rebuilding an identical
message.
+ op
+ } else {
+ builder.build()
+ }
+ }
+
+ /**
+ * Returns `expr` with every `QueryContext` in it removed.
+ *
+ * Used to derive content hashes that must agree between the driver and the
executor even though
+ * only one side sees the interned form - see
`NativeScanPlanDataInjector.sourceKey`. Dropping
+ * contexts makes such a hash independent of how they happen to be encoded.
+ */
+ def stripQueryContexts(expr: ExprOuterClass.Expr): ExprOuterClass.Expr = {
+ val builder = expr.toBuilder
+ walk(builder, _ => None)
+ builder.build()
+ }
+
+ /**
+ * Depth-first walk over every nested message, applying `f` to each
`QueryContext` found on an
+ * `Expr` or `AggExpr`. `f` returns the replacement context, or `None` to
clear the field.
+ */
+ private def walk(
+ builder: Message.Builder,
+ f: ExprOuterClass.QueryContext => Option[ExprOuterClass.QueryContext]):
Unit = {
+ builder match {
+ case e: ExprOuterClass.Expr.Builder if e.hasQueryContext =>
+ f(e.getQueryContext) match {
+ case Some(ctx) => e.setQueryContext(ctx)
+ case None => e.clearQueryContext()
+ }
+ case a: ExprOuterClass.AggExpr.Builder if a.hasQueryContext =>
+ f(a.getQueryContext) match {
+ case Some(ctx) => a.setQueryContext(ctx)
+ case None => a.clearQueryContext()
+ }
+ case _ =>
+ }
+
+ val fields = builder.getDescriptorForType.getFields.iterator()
+ while (fields.hasNext) {
+ val field = fields.next()
+ // Map fields are repeated messages on the wire but reject
`getRepeatedFieldBuilder`, and no
+ // Comet map field has a message value type, so there is nothing to
visit inside them.
+ if (field.getJavaType == FieldDescriptor.JavaType.MESSAGE &&
!field.isMapField) {
+ if (field.isRepeated) {
+ var i = 0
+ val count = builder.getRepeatedFieldCount(field)
+ while (i < count) {
+ walk(builder.getRepeatedFieldBuilder(field, i), f)
+ i += 1
+ }
+ } else if (builder.hasField(field)) {
+ walk(builder.getFieldBuilder(field), f)
+ }
+ }
+ }
+ }
+}
diff --git
a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
index 5b577f41f4..52990cfa3d 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
@@ -360,15 +360,8 @@ object CometNativeScanExec {
scan: CometScanExec): CometNativeScanExec = {
// Generate unique key for this scan so PlanDataInjector can match
common+partition data.
// Multiple scans of same table with different projections/filters get
different keys.
- val common = nativeOp.getNativeScan.getCommon
- val source = common.getSource
- val keyComponents = Seq(
- common.getRequiredSchemaList.toString,
- common.getDataFiltersList.toString,
- common.getProjectionVectorList.toString,
- common.getFieldsList.toString)
- val hashCode = keyComponents.mkString("|").hashCode
- val sourceKey = s"${source}_${hashCode}"
+ // Derived by the injector that will look it up, so the two sides cannot
drift apart.
+ val sourceKey =
NativeScanPlanDataInjector.sourceKey(nativeOp.getNativeScan.getCommon)
val batchScanExec = CometNativeScanExec(
nativeOp,
diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
index b422d60e67..9c4917e2d6 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
@@ -58,7 +58,7 @@ import org.apache.comet.{CometConf, CometExecIterator,
CometRuntimeException, Co
import org.apache.comet.CometSparkSessionExtensions.{isCometShuffleEnabled,
withFallbackReason}
import org.apache.comet.parquet.CometParquetUtils
import org.apache.comet.rules.CometExecRule
-import org.apache.comet.serde.{CometOperatorSerde, Compatible, Incompatible,
OperatorOuterClass, SupportLevel, Unsupported}
+import org.apache.comet.serde.{CometOperatorSerde, Compatible, Incompatible,
OperatorOuterClass, QueryContextInterner, SupportLevel, Unsupported}
import org.apache.comet.serde.OperatorOuterClass.{AggregateMode =>
CometAggregateMode, Operator}
import org.apache.comet.serde.QueryPlanSerde
import org.apache.comet.serde.QueryPlanSerde.{aggExprToProto, exprToProto,
isStringCollationType, supportedSortType}
@@ -325,17 +325,28 @@ private[comet] object NativeScanPlanDataInjector extends
PlanDataInjector {
op.getNativeScan.hasCommon &&
!op.getNativeScan.hasFilePartition
- override def getKey(op: Operator): Option[String] = {
- // Reconstruct the same sourceKey that was used when storing the data
- val common = op.getNativeScan.getCommon
- val source = common.getSource
+ override def getKey(op: Operator): Option[String] =
Some(sourceKey(op.getNativeScan.getCommon))
+
+ /**
+ * The key under which a native scan's planning data is stored and looked
up. Called on the
+ * driver by `CometNativeScanExec.apply` to store, and on the executor by
[[getKey]] to look up
+ * \- both must derive the identical string from the same scan, so this is
the single definition
+ * rather than two mirrored copies.
+ *
+ * Data filters are stripped of their `QueryContext` before hashing: the
executor reads them
+ * back out of the interned plan (see `QueryContextInterner`) while the
driver holds the
+ * un-interned form, so including the context encoding would make the two
sides disagree. Only
+ * data filters can carry a context, so the other components are hashed
as-is.
+ */
+ private[comet] def sourceKey(common: OperatorOuterClass.NativeScanCommon):
String = {
+ val dataFilters = common.getDataFiltersList.asScala
+ .map(QueryContextInterner.stripQueryContexts(_).toString)
val keyComponents = Seq(
common.getRequiredSchemaList.toString,
- common.getDataFiltersList.toString,
+ dataFilters.mkString("[", ", ", "]"),
common.getProjectionVectorList.toString,
common.getFieldsList.toString)
- val hashCode = keyComponents.mkString("|").hashCode
- Some(s"${source}_${hashCode}")
+ s"${common.getSource}_${keyComponents.mkString("|").hashCode}"
}
override def inject(
@@ -869,12 +880,10 @@ abstract class CometNativeExec extends CometExec {
def convertBlock(): CometNativeExec = {
def transform(arg: Any): AnyRef = arg match {
case serializedPlan: SerializedPlan if serializedPlan.isEmpty =>
- val size = nativeOp.getSerializedSize
- val bytes = new Array[Byte](size)
- val codedOutput = CodedOutputStream.newInstance(bytes)
- nativeOp.writeTo(codedOutput)
- codedOutput.checkNoSpaceLeft()
- SerializedPlan(Some(bytes))
+ // Hoist duplicated QueryContext SQL text into a pool on the root
operator. This is the
+ // point where the whole native block is in hand, which is what the
pool indices are
+ // scoped to.
+
SerializedPlan(Some(CometExec.serializeNativePlan(QueryContextInterner.intern(nativeOp))))
case other: AnyRef => other
case null => null
}
diff --git
a/spark/src/test/scala/org/apache/comet/QueryContextInternerSuite.scala
b/spark/src/test/scala/org/apache/comet/QueryContextInternerSuite.scala
new file mode 100644
index 0000000000..fd33f24994
--- /dev/null
+++ b/spark/src/test/scala/org/apache/comet/QueryContextInternerSuite.scala
@@ -0,0 +1,163 @@
+/*
+ * 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.comet
+
+import scala.collection.mutable
+
+import org.apache.spark.sql.{CometTestBase, DataFrame}
+import org.apache.spark.sql.comet.CometNativeExec
+
+import org.apache.comet.serde.{ExprOuterClass, OperatorOuterClass}
+
+class QueryContextInternerSuite extends CometTestBase {
+
+ private val sqlText =
+ """select k1,
+ | sum(d1) as sum_d1,
+ | max(case when i1 > 100 then d1 + 1 else d1 * 2 end) as m1
+ |from t1
+ |where i1 between 1 and 1000 and d1 > 1.0
+ |group by k1
+ |order by sum_d1 desc""".stripMargin
+
+ /** Every `Expr`/`AggExpr` QueryContext in an operator tree, at any nesting
depth. */
+ private def collectContexts(
+ root: OperatorOuterClass.Operator): Seq[ExprOuterClass.QueryContext] = {
+ val found = mutable.ArrayBuffer.empty[ExprOuterClass.QueryContext]
+
+ def walk(m: com.google.protobuf.Message): Unit = {
+ m match {
+ case e: ExprOuterClass.Expr if e.hasQueryContext => found +=
e.getQueryContext
+ case a: ExprOuterClass.AggExpr if a.hasQueryContext => found +=
a.getQueryContext
+ case _ =>
+ }
+ m.getAllFields.forEach { (_, v) =>
+ v match {
+ case child: com.google.protobuf.Message => walk(child)
+ case list: java.util.List[_] =>
+ list.forEach {
+ case child: com.google.protobuf.Message => walk(child)
+ case _ =>
+ }
+ case _ =>
+ }
+ }
+ }
+
+ walk(root)
+ found.toSeq
+ }
+
+ /**
+ * Each native block of `df`'s plan, as the un-interned `nativeOp` (what
would have been
+ * serialized before interning) paired with the bytes actually serialized
for it.
+ */
+ private def nativeBlocks(df: DataFrame): Seq[(OperatorOuterClass.Operator,
Array[Byte])] =
+ stripAQEPlan(df.queryExecution.executedPlan).collect {
+ case n: CometNativeExec if n.serializedPlanOpt.isDefined =>
+ (n.nativeOp, n.serializedPlanOpt.plan.get)
+ }
+
+ private def withTestTable(f: => Unit): Unit = {
+ withTempPath { dir =>
+ spark
+ .range(0, 100)
+ .selectExpr(
+ "cast(id % 17 as int) as k1",
+ "cast(id as decimal(20,4)) as d1",
+ "cast(id % 1000 as int) as i1")
+ .write
+ .mode("overwrite")
+ .parquet(dir.getAbsolutePath)
+ withTempView("t1") {
+ spark.read.parquet(dir.getAbsolutePath).createOrReplaceTempView("t1")
+ f
+ }
+ }
+ }
+
+ test("SQL text is interned into the root operator pool") {
+ withTestTable {
+ val blocks = nativeBlocks(spark.sql(sqlText))
+ assert(blocks.nonEmpty, "expected at least one serialized native block")
+
+ var sawContext = false
+ blocks.foreach { case (_, bytes) =>
+ val root = OperatorOuterClass.Operator.parseFrom(bytes)
+ val contexts = collectContexts(root)
+ if (contexts.nonEmpty) {
+ sawContext = true
+ contexts.foreach { ctx =>
+ assert(
+ ctx.hasSqlTextIdx,
+ s"QueryContext was not interned; carries inline sql_text:
${ctx.getSqlText}")
+ assert(ctx.getSqlText.isEmpty, "interned QueryContext should not
repeat sql_text")
+ assert(ctx.getSqlTextIdx >= 0 && ctx.getSqlTextIdx <
root.getSqlTextPoolCount)
+ }
+ // The query text is stored once for the block, not once per
expression.
+ assert(root.getSqlTextPoolCount == 1, s"pool:
${root.getSqlTextPoolList}")
+ assert(root.getSqlTextPool(0).contains("sum(d1)"))
+ }
+ }
+ assert(sawContext, "expected at least one expression to carry a
QueryContext")
+ }
+ }
+
+ test("interning shrinks the serialized plan") {
+ withTestTable {
+ // `nativeOp` is never interned - only the bytes written for it are - so
its serialized size
+ // is exactly what the plan weighed before this optimization.
+ val blocks = nativeBlocks(spark.sql(sqlText))
+ val before = blocks.map { case (nativeOp, _) =>
nativeOp.getSerializedSize }.sum
+ val after = blocks.map { case (_, bytes) => bytes.length }.sum
+
+ assert(
+ before > after * 4,
+ s"expected interning to shrink the plan substantially: $before ->
$after")
+ }
+ }
+
+ test("ANSI error still reports full SQL context after interning") {
+ // Deliberately its own integer-typed table rather than reusing
`withTestTable`: integer
+ // division routes through the native `CheckedBinaryExpr`, which is what
consults the
+ // QueryContext registry. Decimal division raises a plain compute error
with no SQL context,
+ // so it would not exercise this path at all.
+ withSQLConf("spark.sql.ansi.enabled" -> "true") {
+ withTempPath { dir =>
+ spark
+ .range(1, 10)
+ .selectExpr("cast(id as int) as a", "cast(0 as int) as b")
+ .write
+ .mode("overwrite")
+ .parquet(dir.getAbsolutePath)
+ withTempView("t_div") {
+
spark.read.parquet(dir.getAbsolutePath).createOrReplaceTempView("t_div")
+
+ val query = "select a / b as ratio from t_div"
+ val err = intercept[Exception](spark.sql(query).collect())
+ val msg = Option(err.getMessage).getOrElse("")
+ assert(msg.contains("DIVIDE_BY_ZERO"), msg)
+ // The pooled text must round-trip through native: the summary
echoes the full query.
+ assert(msg.contains(query), s"error message lost the SQL
context:\n$msg")
+ }
+ }
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]