-
Notifications
You must be signed in to change notification settings - Fork 352
feat: default native replace for non-empty UTF8_BINARY literal search #5409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3b0d9d4
d223e8f
db8e31d
80c0737
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| // 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. | ||
|
|
||
| use std::fmt::Formatter; | ||
| use std::sync::Arc; | ||
|
|
||
| use datafusion::common::{internal_err, Result}; | ||
| use datafusion::execution::TaskContext; | ||
| use datafusion::physical_plan::metrics::{ExecutionPlanMetricsSet, MetricsSet, SplitMetrics}; | ||
| use datafusion::physical_plan::stream::BatchSplitStream; | ||
| use datafusion::physical_plan::{ | ||
| DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, SendableRecordBatchStream, | ||
| }; | ||
|
|
||
| /// Splits oversized batches emitted by an input plan using the runtime batch size. | ||
| /// | ||
| /// DataFusion 54.1.0's `UnnestExec` can emit more rows than the configured batch size. This | ||
| /// wrapper is a temporary downstream boundary for Comet's explode path until Comet upgrades to a | ||
| /// DataFusion version containing https://github.com/apache/datafusion/pull/24384. | ||
| #[derive(Debug)] | ||
| pub struct BatchSplitExec { | ||
| input: Arc<dyn ExecutionPlan>, | ||
| cache: Arc<PlanProperties>, | ||
| metrics: ExecutionPlanMetricsSet, | ||
| } | ||
|
|
||
| impl BatchSplitExec { | ||
| pub fn new(input: Arc<dyn ExecutionPlan>) -> Self { | ||
| Self { | ||
| cache: Arc::clone(input.properties()), | ||
| input, | ||
| metrics: ExecutionPlanMetricsSet::new(), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl DisplayAs for BatchSplitExec { | ||
| fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter) -> std::fmt::Result { | ||
| match t { | ||
| DisplayFormatType::Default | DisplayFormatType::Verbose => { | ||
| write!(f, "CometBatchSplitExec") | ||
| } | ||
| DisplayFormatType::TreeRender => unimplemented!(), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl ExecutionPlan for BatchSplitExec { | ||
| fn name(&self) -> &str { | ||
| "CometBatchSplitExec" | ||
| } | ||
|
|
||
| fn properties(&self) -> &Arc<PlanProperties> { | ||
| &self.cache | ||
| } | ||
|
|
||
| fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> { | ||
| vec![&self.input] | ||
| } | ||
|
|
||
| fn with_new_children( | ||
| self: Arc<Self>, | ||
| children: Vec<Arc<dyn ExecutionPlan>>, | ||
| ) -> Result<Arc<dyn ExecutionPlan>> { | ||
| if children.len() != 1 { | ||
| return internal_err!( | ||
| "CometBatchSplitExec expects one child, got {}", | ||
| children.len() | ||
| ); | ||
| } | ||
| Ok(Arc::new(Self::new(Arc::clone(&children[0])))) | ||
| } | ||
|
|
||
| fn execute( | ||
| &self, | ||
| partition: usize, | ||
| context: Arc<TaskContext>, | ||
| ) -> Result<SendableRecordBatchStream> { | ||
| let batch_size = context.session_config().batch_size(); | ||
| let input = self.input.execute(partition, context)?; | ||
| Ok(Box::pin(BatchSplitStream::new( | ||
| input, | ||
| batch_size, | ||
| SplitMetrics::new(&self.metrics, partition), | ||
| ))) | ||
| } | ||
|
|
||
| fn metrics(&self) -> Option<MetricsSet> { | ||
| Some(self.metrics.clone_inner()) | ||
| } | ||
|
|
||
| fn maintains_input_order(&self) -> Vec<bool> { | ||
| vec![true] | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| use arrow::array::{AsArray, Int32Array, RecordBatch}; | ||
| use arrow::datatypes::{DataType, Field, Schema}; | ||
| use datafusion::datasource::memory::MemorySourceConfig; | ||
| use datafusion::datasource::source::DataSourceExec; | ||
| use datafusion::execution::SessionStateBuilder; | ||
| use datafusion::prelude::{SessionConfig, SessionContext}; | ||
| use futures::StreamExt; | ||
|
|
||
| #[tokio::test] | ||
| async fn splits_batches_at_the_runtime_batch_size_and_preserves_order() { | ||
| let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])); | ||
| let input_batch = RecordBatch::try_new( | ||
| Arc::clone(&schema), | ||
| vec![Arc::new(Int32Array::from_iter_values(0..10))], | ||
| ) | ||
| .unwrap(); | ||
| let source = MemorySourceConfig::try_new(&[vec![input_batch]], schema, None).unwrap(); | ||
| let input: Arc<dyn ExecutionPlan> = Arc::new(DataSourceExec::new(Arc::new(source))); | ||
| let split = BatchSplitExec::new(input); | ||
|
|
||
| let config = SessionConfig::new().with_batch_size(4); | ||
| let state = SessionStateBuilder::new().with_config(config).build(); | ||
| let context = SessionContext::new_with_state(state); | ||
| let mut stream = split.execute(0, context.task_ctx()).unwrap(); | ||
|
|
||
| let mut batch_sizes = vec![]; | ||
| let mut values = vec![]; | ||
| while let Some(batch) = stream.next().await { | ||
| let batch = batch.unwrap(); | ||
| batch_sizes.push(batch.num_rows()); | ||
| let column = batch | ||
| .column(0) | ||
| .as_primitive::<arrow::datatypes::Int32Type>(); | ||
| values.extend(column.values().iter().copied()); | ||
| } | ||
|
|
||
| assert_eq!(batch_sizes, vec![4, 4, 2]); | ||
| assert_eq!(values, (0..10).collect::<Vec<_>>()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,8 +19,12 @@ | |
|
|
||
| package org.apache.comet.serde | ||
|
|
||
| import org.apache.spark.sql.catalyst.expressions.{Attribute, Base64, BitLength, Cast, Concat, ConcatWs, Contains, Elt, Empty2Null, EndsWith, Expression, FindInSet, FormatNumber, FormatString, GetJsonObject, InitCap, Left, Length, Levenshtein, Like, Literal, Lower, Mask, OctetLength, Overlay, RegExpExtract, RegExpExtractAll, RegExpInStr, RegExpReplace, Right, RLike, SoundEx, StartsWith, StringLocate, StringLPad, StringRepeat, StringReplace, StringRPad, StringSplit, StringTranslate, Substring, SubstringIndex, ToCharacter, ToNumber, TryToNumber, UnBase64, Upper} | ||
| import java.nio.charset.StandardCharsets | ||
| import java.util.Arrays | ||
|
|
||
| import org.apache.spark.sql.catalyst.expressions.{Attribute, Base64, BitLength, BoundReference, Cast, Concat, ConcatWs, Contains, Elt, Empty2Null, EndsWith, Expression, FindInSet, FormatNumber, FormatString, GetJsonObject, InitCap, Left, Length, Levenshtein, Like, Literal, Lower, Mask, OctetLength, Overlay, RegExpExtract, RegExpExtractAll, RegExpInStr, RegExpReplace, Right, RLike, SoundEx, StartsWith, StringLocate, StringLPad, StringRepeat, StringReplace, StringRPad, StringSplit, StringTranslate, Substring, SubstringIndex, ToCharacter, ToNumber, TryToNumber, UnBase64, Upper} | ||
| import org.apache.spark.sql.types.{BinaryType, DataTypes, IntegerType, LongType, StringType} | ||
| import org.apache.spark.unsafe.types.UTF8String | ||
|
|
||
| import org.apache.comet.CometConf | ||
| import org.apache.comet.serde.ExprOuterClass.Expr | ||
|
|
@@ -178,29 +182,93 @@ object CometStringReplace | |
| extends CometScalarFunction[StringReplace]("replace") | ||
| with NativeOptInAvailable { | ||
|
|
||
| /** | ||
| * The DataFusion `replace` kernel matches Spark only for a non-empty search string. Kernel | ||
| * compatibility is not enough: `CometLiteral` serializes strings via `UTF8String.toString` | ||
| * (malformed UTF-8 becomes U+FFFD), DataFusion evaluates every child before `replace` (so a | ||
| * NULL `src` does not skip a throwing replacement), and scalar literals are broadcast into | ||
| * Arrow `Utf8` arrays that overflow 32-bit offsets on a large batch. | ||
| * | ||
| * The default native path is therefore limited to a plan-time subset that avoids those | ||
| * boundaries. `src` uses the same whitelist as `replace`: a short well-formed literal or a | ||
| * column. Nested expressions (`substring`, `concat`, …) stay on the dispatcher so a throwing or | ||
| * malformed child cannot hide inside the source tree. Non-default collations stay on the | ||
| * dispatcher. https://github.com/apache/datafusion-comet/issues/4496 | ||
| */ | ||
| private def nativeSafeSubset(expr: StringReplace): Boolean = { | ||
| val children = expr.children | ||
| if (children.length != 3) { | ||
| return false | ||
| } | ||
| val sourceIsSafe = isNativeSafeStringArg(children(0), allowEmptyLiteral = true) | ||
| val searchIsSafe = children(1) match { | ||
| case Literal(v: UTF8String, _) => isNativeSafeStringLiteral(v, allowEmpty = false) | ||
| case _ => false | ||
| } | ||
| val replacementIsSafe = isNativeSafeStringArg(children(2), allowEmptyLiteral = true) | ||
| val utf8BinaryCollation = | ||
| !children.exists(c => QueryPlanSerde.isStringCollationType(c.dataType)) | ||
| utf8BinaryCollation && sourceIsSafe && searchIsSafe && replacementIsSafe | ||
| } | ||
|
|
||
| /** A column, null literal, or a short well-formed string literal. */ | ||
| private def isNativeSafeStringArg(expr: Expression, allowEmptyLiteral: Boolean): Boolean = | ||
| expr match { | ||
| case Literal(null, _) => true | ||
| case Literal(v: UTF8String, _) => isNativeSafeStringLiteral(v, allowEmptyLiteral) | ||
| case _: Attribute | _: BoundReference => true | ||
| case _ => false | ||
| } | ||
|
|
||
| /** | ||
| * `CometLiteral` encodes a string as `UTF8String.toString`, so only literals whose bytes | ||
| * survive that round-trip can be sent natively. The size cap keeps a broadcast scalar under | ||
| * Arrow `Utf8`'s 32-bit offset limit. Native operators feeding projections must emit batches no | ||
| * larger than `spark.comet.batchSize`; Comet's explode path enforces that with | ||
| * `BatchSplitExec`. | ||
| */ | ||
| private def isNativeSafeStringLiteral(v: UTF8String, allowEmpty: Boolean): Boolean = { | ||
| if (v == null) { | ||
| return false | ||
| } | ||
| if (!allowEmpty && v.numBytes() == 0) { | ||
| return false | ||
| } | ||
| val maxBytes = Int.MaxValue / math.max(CometConf.COMET_BATCH_SIZE.get(), 1) | ||
| if (v.numBytes() > maxBytes) { | ||
| return false | ||
| } | ||
| Arrays.equals(v.getBytes, v.toString.getBytes(StandardCharsets.UTF_8)) | ||
| } | ||
|
|
||
| override def getCompatibleNotes(): Seq[String] = | ||
| Seq( | ||
| "When `src` and `replace` are each a short well-formed literal or a column, and " + | ||
| "`search` is a short, well-formed, non-empty `UTF8_BINARY` literal, Comet evaluates " + | ||
| "`replace` natively by default.") | ||
|
|
||
| override def getIncompatibleReasons(): Seq[String] = | ||
| Seq("Produces different results from Spark when the search string is empty") | ||
|
|
||
| override def getSupportLevel(expr: StringReplace): SupportLevel = | ||
| if (!CometConf.isExprAllowIncompat(getExprConfigName(expr))) { | ||
| if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSubset(expr)) { | ||
| Compatible() | ||
| } else { | ||
| Compatible(nativeOptIn = | ||
| Some(NativeOptIn(CometConf.getExprAllowIncompatConfigKey(getExprConfigName(expr))))) | ||
| } else { | ||
| Compatible() | ||
| } | ||
|
|
||
| override def convert( | ||
| expr: StringReplace, | ||
| inputs: Seq[Attribute], | ||
| binding: Boolean): Option[Expr] = { | ||
| if (CometConf.isExprAllowIncompat(getExprConfigName(expr))) { | ||
| // The native DataFusion `replace` avoids the JVM allocations of the codegen | ||
| // dispatcher but is not Spark-compatible for an empty search string, so it is | ||
| // only used when incompatibility is explicitly allowed. | ||
| if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSubset(expr)) { | ||
| // Native path for the plan-time safe subset, or when the user has opted in. | ||
| super.convert(expr, inputs, binding) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Preserve NULL short-circuiting for replacement expressions With ANSI enabled and Parquet rows
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. Spark's ternary |
||
| } else { | ||
| // Run Spark's own generated code inside the Comet pipeline so the result matches Spark | ||
| // exactly. Falls back to Spark when the codegen dispatcher is disabled. | ||
| // Nested / malformed / oversized / throwing children, or a non-default collation: run | ||
| // Spark's own generated code inside the Comet pipeline. Falls back to Spark when the | ||
| // codegen dispatcher is disabled. | ||
| CometScalaUDF.emitJvmCodegenDispatch(expr, inputs, binding) | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Preserve the explode metrics through the new wrapper
Replacing
UnnestExecas theSparkPlanroot also drops its SQL metrics.to_native_metric_nodereads this root'smetrics()and then itsSparkPlanchildren; it never visits the wrapped native child.BatchSplitExec::metrics()returns onlybatches_split, whichCometExplodeExecdoes not expose, so its existing input/output row and batch counters andelapsed_computenow remain zero for every native explode, even when no split is needed. A focused Spark 4.0.4 probe over 8,192 short-string rows returned all 16,384 exploded rows, but all five metrics were zero with the matching PR native artifact; the control runtime reportedinput_rows=8192andoutput_rows=16384. The exact-source Rust probe independently confirms that the populated unnest metrics are hidden by the wrapper. Please preserve those metrics, includingoutput_rows(the existing additional-plan aggregation deliberately skips that field), and add a metric regression check.