comphead commented on code in PR #5292:
URL: https://github.com/apache/datafusion-comet/pull/5292#discussion_r4488343916


##########
native/spark-expr/src/datetime_funcs/make_interval.rs:
##########
@@ -16,72 +16,270 @@
 // under the License.
 
 use crate::arithmetic_overflow_error;
-use arrow::array::Array;
-use arrow::datatypes::DataType;
-use datafusion::common::Result;
-use datafusion::logical_expr::{ColumnarValue, ScalarFunctionArgs, 
ScalarUDFImpl, Signature};
-use datafusion_spark::function::datetime::make_interval::SparkMakeInterval as 
DataFusionMakeInterval;
+use arrow::array::{Array, ArrayRef, Decimal128Array, Int32Array, Int64Array, 
StructArray};
+use arrow::buffer::NullBuffer;
+use arrow::datatypes::{DataType, Field, Fields};
+use datafusion::common::{DataFusionError, Result};
+use datafusion::logical_expr::{
+    ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, Volatility,
+};
+use std::collections::HashMap;
+use std::sync::Arc;
+
+const CALENDAR_INTERVAL_STRUCT_KEY: &str = "SPARK::calendarInterval::struct";
+const MICROS_PER_HOUR: i64 = 3_600_000_000;
+const MICROS_PER_MINUTE: i64 = 60_000_000;
+
+pub fn calendar_interval_type() -> DataType {
+    let months = Field::new("months", DataType::Int32, 
false).with_metadata(HashMap::from([(
+        CALENDAR_INTERVAL_STRUCT_KEY.to_string(),
+        "true".to_string(),
+    )]));
+    DataType::Struct(Fields::from(vec![
+        months,
+        Field::new("days", DataType::Int32, false),
+        Field::new("microseconds", DataType::Int64, false),
+    ]))
+}
+
+/// Whether `fields` are the children of a Spark `CalendarIntervalType` value, 
i.e. of the tagged
+/// struct built by [`calendar_interval_type`].
+///
+/// The `SPARK::calendarInterval::struct` marker on `months` is what 
identifies an interval: a
+/// user's own `struct<months:int, days:int, microseconds:bigint>` has the 
same shape and must keep
+/// ordinary struct semantics. Child nullability is deliberately not compared: 
the struct's own
+/// validity decides whether an interval is null, and a nullability widening 
that keeps field
+/// metadata must not turn an interval into a plain struct, which for the hash 
kernels would mean
+/// silently hashing it as one. The JVM counterpart is 
`Utils.isCalendarIntervalStructField`.
+pub fn is_calendar_interval_fields(fields: &Fields) -> bool {
+    let child = |index: usize, name: &str, data_type: &DataType| {
+        fields[index].name() == name && fields[index].data_type() == data_type
+    };
+    fields.len() == 3
+        && child(0, "months", &DataType::Int32)
+        && child(1, "days", &DataType::Int32)
+        && child(2, "microseconds", &DataType::Int64)
+        && fields[0]
+            .metadata()
+            .get(CALENDAR_INTERVAL_STRUCT_KEY)
+            .is_some_and(|value| value == "true")
+}
 
 #[derive(Debug, PartialEq, Eq, Hash)]
 pub struct SparkMakeInterval {
-    inner: DataFusionMakeInterval,
+    signature: Signature,
     fail_on_error: bool,
 }
 
 impl SparkMakeInterval {
     pub fn new(fail_on_error: bool) -> Self {
         Self {
-            inner: DataFusionMakeInterval::new(),
+            signature: Signature::exact(
+                vec![
+                    DataType::Int32,
+                    DataType::Int32,
+                    DataType::Int32,
+                    DataType::Int32,
+                    DataType::Int32,
+                    DataType::Int32,
+                    DataType::Decimal128(18, 6),
+                ],
+                Volatility::Immutable,
+            ),
             fail_on_error,
         }
     }
 }
 
+fn make_interval(
+    years: i32,
+    months: i32,
+    weeks: i32,
+    days: i32,
+    hours: i32,
+    minutes: i32,
+    seconds_micros: i128,
+) -> std::result::Result<(i32, i32, i64), &'static str> {
+    let months = years
+        .checked_mul(12)
+        .and_then(|years| years.checked_add(months))
+        .ok_or("integer")?;
+    let days = weeks
+        .checked_mul(7)
+        .and_then(|weeks| weeks.checked_add(days))
+        .ok_or("integer")?;
+    let micros = i64::try_from(seconds_micros)
+        .ok()
+        .and_then(|seconds| {
+            i64::from(hours)
+                .checked_mul(MICROS_PER_HOUR)
+                .and_then(|hours| seconds.checked_add(hours))
+        })
+        .and_then(|micros| {
+            i64::from(minutes)
+                .checked_mul(MICROS_PER_MINUTE)
+                .and_then(|minutes| micros.checked_add(minutes))
+        })
+        .ok_or("long")?;
+    Ok((months, days, micros))
+}
+
 impl ScalarUDFImpl for SparkMakeInterval {
     fn name(&self) -> &str {
-        self.inner.name()
+        "make_interval"
     }
 
     fn signature(&self) -> &Signature {
-        self.inner.signature()
+        &self.signature
     }
 
-    fn return_type(&self, arg_types: &[DataType]) -> Result<DataType> {
-        self.inner.return_type(arg_types)
+    fn return_type(&self, _: &[DataType]) -> Result<DataType> {
+        Ok(calendar_interval_type())
     }
 
     fn invoke_with_args(&self, args: ScalarFunctionArgs) -> 
Result<ColumnarValue> {
-        let inputs = if self.fail_on_error {
-            Some(args.args.clone())
-        } else {
-            None
-        };
-        let result = self.inner.invoke_with_args(args)?;
-
-        if let Some(inputs) = inputs {
-            let inputs_are_valid = |i| {
-                inputs.iter().all(|input| match input {
-                    ColumnarValue::Array(values) => values.is_valid(i),
-                    ColumnarValue::Scalar(value) => !value.is_null(),
-                })
-            };
-            let overflow = match &result {
-                ColumnarValue::Array(values) => 
values.nulls().is_some_and(|nulls| {
-                    nulls.null_count() != 0
-                        && nulls
-                            .iter()
-                            .enumerate()
-                            .any(|(i, is_valid)| !is_valid && 
inputs_are_valid(i))
-                }),
-                ColumnarValue::Scalar(value) => value.is_null() && 
inputs_are_valid(0),
-            };
-            if overflow {
-                // Spark identifies the integer or long operation that 
overflowed. The native
-                // wrapper only sees the result null mask, so it can only 
report interval overflow.
-                return Err(arithmetic_overflow_error("interval").into());
+        let number_rows = args.number_rows;
+        let arrays = args
+            .args
+            .into_iter()
+            .map(|arg| arg.into_array(number_rows))
+            .collect::<Result<Vec<_>>>()?;
+        let years = arrays[0]
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .ok_or_else(|| {
+                DataFusionError::Execution("make_interval: expected Int32 
years".into())
+            })?;
+        let months = arrays[1]
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .ok_or_else(|| {
+                DataFusionError::Execution("make_interval: expected Int32 
months".into())
+            })?;
+        let weeks = arrays[2]
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .ok_or_else(|| {
+                DataFusionError::Execution("make_interval: expected Int32 
weeks".into())
+            })?;
+        let days = arrays[3]
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .ok_or_else(|| {
+                DataFusionError::Execution("make_interval: expected Int32 
days".into())
+            })?;
+        let hours = arrays[4]
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .ok_or_else(|| {
+                DataFusionError::Execution("make_interval: expected Int32 
hours".into())
+            })?;
+        let minutes = arrays[5]
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .ok_or_else(|| {
+                DataFusionError::Execution("make_interval: expected Int32 
minutes".into())
+            })?;
+        let seconds = arrays[6]
+            .as_any()
+            .downcast_ref::<Decimal128Array>()
+            .ok_or_else(|| {
+                DataFusionError::Execution("make_interval: expected Decimal128 
seconds".into())
+            })?;
+
+        let mut result_months = Vec::with_capacity(years.len());
+        let mut result_days = Vec::with_capacity(years.len());
+        let mut result_micros = Vec::with_capacity(years.len());
+        let mut valid = Vec::with_capacity(years.len());
+
+        for i in 0..years.len() {
+            if arrays.iter().any(|array| array.is_null(i)) {
+                result_months.push(0);
+                result_days.push(0);
+                result_micros.push(0);
+                valid.push(false);
+                continue;
+            }

Review Comment:
   This checks all seven arrays for NULL on every row through `dyn Array`, and 
builds a `Vec<bool>` for the result validity. Could the validity come from 
folding the seven null buffers with `NullBuffer::union` once per batch, so the 
loop only skips the rows that buffer marks NULL and then passes it straight to 
`StructArray::new`? The seven downcast blocks above could also be 
`datafusion::common::cast::as_int32_array` and `as_decimal128_array`, which 
return an error the same way and are already used in `list_extract.rs` and 
`abs.rs`. A rerun of the benchmark was requested anyway, so the `make_interval` 
criterion bench would show whether it matters. I have not measured it.



##########
spark/src/main/scala/org/apache/comet/serde/datetime.scala:
##########
@@ -907,35 +907,60 @@ object CometMakeDTInterval extends 
CometCodegenDispatch[MakeDTInterval]
 object CometDivideDTInterval extends CometCodegenDispatch[DivideDTInterval]
 
 object CometMakeInterval extends CometExpressionSerde[MakeInterval] with 
CodegenDispatchFallback {
-  private val incompatReason =
-    "The native implementation converts seconds to `Float64`, which can lose 
microsecond" +
-      " precision, and stores time in nanoseconds, which overflows for large 
time components" +
-      " (hours, minutes, seconds) that Spark can represent."
 
-  override def getCompatibleNotes(): Seq[String] = Seq(
-    "Both the default JVM codegen-dispatch path and the native path currently 
limit the" +
-      " elapsed-time component to about 292 years in either direction. This 
only affects" +
-      " extreme intervals and is tracked in" +
-      " [#5279](https://github.com/apache/datafusion-comet/issues/5279).")
+  private val eagerArgumentReason =
+    "An argument that follows a nullable argument and is not a column 
reference, a literal, or " +
+      "a lossless up-cast of one, such as an ANSI `CAST` that can fail: Spark 
stops at the " +
+      "first NULL argument without evaluating the rest, while the native 
kernel evaluates every " +
+      "argument first"
 
-  override def getIncompatibleReasons(): Seq[String] = Seq(incompatReason)
+  override def getUnsupportedReasons(): Seq[String] = Seq(eagerArgumentReason)
 
   override def getSupportLevel(expr: MakeInterval): SupportLevel =
-    Incompatible(Some(incompatReason))
+    if (argumentsCanBeEvaluatedEagerly(expr)) Compatible()
+    else Unsupported(Some(eagerArgumentReason))
+
+  /**
+   * Spark's `MakeInterval` is a `SeptenaryExpression`: it evaluates its 
arguments left to right
+   * and returns NULL at the first NULL one, so an argument that follows a 
nullable argument only
+   * runs on the rows where every earlier argument is non-null. The native 
scalar function
+   * evaluates every argument over the whole batch before its kernel checks 
for nulls. Running
+   * such an argument on the extra rows is unobservable only when it cannot 
throw, carry state or
+   * have a side effect. That holds for a column reference, a literal, and a 
lossless up-cast of
+   * either, which covers the implicit casts Spark adds to widen integral and 
decimal arguments.
+   * Anything else routes through the JVM codegen dispatcher, which runs 
Spark's own nested
+   * evaluation. `foldable` is not a usable test: an unfolded foldable 
argument can still throw.
+   * Arguments before the first nullable one run on every row in Spark too, so 
they are
+   * unrestricted.
+   */
+  private def argumentsCanBeEvaluatedEagerly(expr: MakeInterval): Boolean = {
+    def unobservable(arg: Expression): Boolean = arg match {
+      case _: Literal | _: Attribute | _: BoundReference => true
+      case cast: Cast =>
+        Cast.canUpCast(cast.child.dataType, cast.dataType) && 
unobservable(cast.child)
+      case _ => false
+    }
+    val firstNullable = expr.children.indexWhere(_.nullable)
+    firstNullable < 0 || expr.children.drop(firstNullable + 
1).forall(unobservable)
+  }

Review Comment:
   Since this was written, #6716 added `QueryPlanSerde.withNullShortCircuit`, 
which sets `null_short_circuit` on the `ScalarFunc` so that native 
`NullShortCircuit::wrap` evaluates each argument after the first only on rows 
where no earlier argument is NULL. That is the `SeptenaryExpression` evaluation 
order this check protects. `adding_a_new_expression.md` now points serdes at 
it, and `CometArrayContains` and `CometSlice` already use it. Could `convert` 
wrap the `scalarFunctionExprToProtoWithReturnType` call in 
`withNullShortCircuit(expr, ...)` instead? Then `make_interval(y, CAST(s AS 
INT))` would stay native rather than going to the dispatcher, and 
`argumentsCanBeEvaluatedEagerly`, `eagerArgumentReason` and the `Unsupported` 
arm could go. The `expect_dispatch(make_interval)` short-circuit query in 
`make_interval_ansi.sql` would then become a native one. I have not run it.



##########
spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowWriters.scala:
##########
@@ -97,6 +97,8 @@ private[arrow] object ArrowWriter {
       case (_: DayTimeIntervalType, vector: DurationVector) => new 
DurationWriter(vector)
       case (CalendarIntervalType, vector: IntervalMonthDayNanoVector) =>
         new IntervalMonthDayNanoWriter(vector)

Review Comment:
   With `toArrowField` now building the struct, I could not find anything that 
still creates an `IntervalMonthDayNanoVector` for a `CalendarIntervalType` 
column. Every `ArrowWriter.create` caller builds its root from 
`Utils.toArrowSchema`, and no native type maps to `Interval(MonthDayNano)` any 
more. That leaves the #4898 read and write paths unused: this arm and 
`IntervalMonthDayNanoWriter`, `IntervalChildVector` and `intervalChildren` in 
`CometPlainVector.java`, the `IntervalMonthDayNanoVector` entries in 
`CometBatchKernelCodegen.vectorClassBySimpleName`, 
`CometBatchKernelCodegenInput` (the class list and the 
`IntervalMonthDayNanoVector` condition of the `getInterval` case) and 
`CometScalaUDFCodegen.specFor`, the `MONTH_DAY_NANO` arm of 
`Utils.fromArrowType`, and the `IntervalMonthDayNano(None)` null literal in the 
native planner (now in `planner/literal.rs` on main). Could they go in this PR, 
since it retires that layout? Then a stray `Interval(MonthDayNano)` would fail 
loudly ins
 tead of being read with the old nanosecond conversion.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to