Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions native/spark-expr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ path = "src/lib.rs"
name = "cast_from_string"
harness = false

[[bench]]
name = "cast_string_to_date"
harness = false

[[bench]]
name = "cast_numeric"
harness = false
Expand Down
84 changes: 84 additions & 0 deletions native/spark-expr/benches/cast_string_to_date.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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 arrow::array::{builder::StringBuilder, RecordBatch};
use arrow::datatypes::{DataType, Field, Schema};
use criterion::{criterion_group, criterion_main, Criterion};
use datafusion::physical_expr::{expressions::Column, PhysicalExpr};
use datafusion_comet_spark_expr::{Cast, EvalMode, SparkCastOptions};
use std::sync::Arc;

const BATCH_SIZE: usize = 8192;

fn criterion_benchmark(c: &mut Criterion) {
let expr = Arc::new(Column::new("a", 0));
let cast_to_date = Cast::new(
expr,
DataType::Date32,
SparkCastOptions::new(EvalMode::Legacy, "UTC", false),
None,
None,
);

let canonical =
create_batch(|i| format!("{:04}-{:02}-{:02}", 1970 + i % 60, i % 12 + 1, i % 28 + 1));
let mixed = create_batch(|i| match i % 5 {
0 => format!("{:04}-{:02}-{:02}", 1970 + i % 60, i % 12 + 1, i % 28 + 1),
1 => format!("{}-{}-{} 12:34:56", 1970 + i % 60, i % 12 + 1, i % 28 + 1),
2 => format!(
" {:04}-{:02}-{:02} ",
1900 + i % 200,
i % 12 + 1,
i % 28 + 1
),
3 => format!("{:04}", 1970 + i % 60),
_ => "not a date".to_string(),
});

let mut group = c.benchmark_group("cast_string_to_date");
group.bench_function("canonical", |b| {
b.iter(|| cast_to_date.evaluate(&canonical).unwrap());
});
group.bench_function("mixed", |b| {
b.iter(|| cast_to_date.evaluate(&mixed).unwrap());
});
group.finish();
}

fn create_batch(f: impl Fn(usize) -> String) -> RecordBatch {
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Utf8, true)]));
let mut builder = StringBuilder::new();
for i in 0..BATCH_SIZE {
if i % 17 == 0 {
builder.append_null();
} else {
builder.append_value(f(i));
}
}
RecordBatch::try_new(schema, vec![Arc::new(builder.finish())]).unwrap()
}

fn config() -> Criterion {
Criterion::default()
}

criterion_group! {
name = benches;
config = config();
targets = criterion_benchmark
}
criterion_main!(benches);
132 changes: 68 additions & 64 deletions native/spark-expr/src/conversion_funcs/string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,11 @@ use arrow::array::{
Array, ArrayRef, ArrowPrimitiveType, BooleanArray, Decimal128Builder, GenericStringArray,
OffsetSizeTrait, PrimitiveArray, PrimitiveBuilder, StringArray,
};
use arrow::compute::DecimalCast;
use arrow::datatypes::{
i256, is_validate_decimal_precision, DataType, Date32Type, Decimal256Type, Float32Type,
Float64Type, Int16Type, Int32Type, Int64Type, Int8Type, TimestampMicrosecondType,
};
use chrono::{DateTime, LocalResult, NaiveDate, NaiveTime, Offset, TimeZone, Timelike};
use chrono::{LocalResult, NaiveDate, NaiveTime, Offset, TimeZone, Timelike};
use num::traits::CheckedNeg;
use num::{CheckedSub, Integer};
use regex::Regex;
Expand Down Expand Up @@ -1161,6 +1160,25 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
era * 146097 + doe - 719468
}

fn is_leap_year(year: i64) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}

/// Days since 1970-01-01 for a proleptic Gregorian year/month/day, or `None` when the
/// combination is not a real calendar date. Unlike `NaiveDate::from_ymd_opt`, this accepts
/// any year that fits in `i64`.
fn ymd_to_epoch_day(year: i64, month: i64, day: i64) -> Option<i64> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

date_parser_test (native/spark-expr/src/conversion_funcs/string.rs:2712) never feeds a canonical-shape dddd-dd-dd string that is an invalid calendar date, so the branch where the fast path calls ymd_to_epoch_day and gets None is untested. The existing invalid-date cases (2020-010-01, 202, 2020-\r8) all fall through to the general parser because they are not 10-char dddd-dd-dd. A regression that made the fast path skip calendar validation would pass the current suite.

Suggested change: add to the invalid list in date_parser_test (which asserts None for Legacy/Try and is_err() for Ansi is wrong here, see finding 2, so put these in a Legacy/Try-only null assertion) canonical invalid dates and one valid boundary:

// invalid calendar dates in canonical yyyy-mm-dd form (exercise the fast path)
for date in &["2020-02-30", "2021-02-29", "2020-13-01", "2020-00-15", "2020-04-31", "2020-01-00"] {
    for eval_mode in &[EvalMode::Legacy, EvalMode::Try, EvalMode::Ansi] {
        assert_eq!(date_parser(date, *eval_mode).unwrap(), None);
    }
}
// valid leap day through the fast path
assert_eq!(date_parser("2020-02-29", EvalMode::Legacy).unwrap(), Some(18321));

const DAYS_IN_MONTH: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut max_day = *DAYS_IN_MONTH.get(usize::try_from(month.checked_sub(1)?).ok()?)?;
if month == 2 && is_leap_year(year) {
max_day = 29;
}
if day < 1 || day > max_day {
return None;
}
Some(days_from_civil(year, month, day))
}

fn parse_timestamp_to_micros<T: TimeZone>(
timestamp_info: &TimeStampInfo,
tz: &T,
Expand Down Expand Up @@ -1226,27 +1244,11 @@ fn parse_timestamp_to_micros<T: TimeZone>(
return Ok(None);
}
// Validate month and day manually for extreme years.
let m = timestamp_info.month;
let d = timestamp_info.day;
if !(1..=12).contains(&m) {
return Ok(None);
}
let max_day = match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
if leap {
29
} else {
28
}
}
_ => return Ok(None),
};
if d < 1 || d > max_day {
return Ok(None);
}
let days =
match ymd_to_epoch_day(year, timestamp_info.month as i64, timestamp_info.day as i64) {
Some(days) => days,
None => return Ok(None),
};
// Compute the timezone offset using epoch as a surrogate probe point.
// Extreme-year timestamps are only valid with a UTC-like fixed offset (any DST
// zone would overflow). Using epoch gives us the standard offset.
Expand All @@ -1263,7 +1265,6 @@ fn parse_timestamp_to_micros<T: TimeZone>(
// Use i128 for the intermediate multiply-by-1_000_000 step: the seconds value can be
// just outside the i64 range while the final microseconds result is still within range
// (e.g., Long.MinValue boundary: seconds = -9_223_372_036_855, result = i64::MIN).
let days = days_from_civil(year, m as i64, d as i64);
let time_secs = timestamp_info.hour as i64 * 3600
+ timestamp_info.minute as i64 * 60
+ timestamp_info.second as i64;
Expand All @@ -1281,33 +1282,16 @@ fn parse_timestamp_to_micros<T: TimeZone>(

fn local_datetime_to_micros(timestamp_info: &TimeStampInfo) -> SparkResult<Option<i64>> {
let year = timestamp_info.year as i64;
let m = timestamp_info.month;
let d = timestamp_info.day;

if !(1..=12).contains(&m) {
return Ok(None);
}
let max_day = match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31u32,
4 | 6 | 9 | 11 => 30,
2 => {
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
if leap {
29
} else {
28
}
}
_ => return Ok(None),
let days = match ymd_to_epoch_day(year, timestamp_info.month as i64, timestamp_info.day as i64)
{
Some(days) => days,
None => return Ok(None),
};
if d < 1 || d > max_day {
return Ok(None);
}
if timestamp_info.hour >= 24 || timestamp_info.minute >= 60 || timestamp_info.second >= 60 {
return Ok(None);
}

let days = days_from_civil(year, m as i64, d as i64);
let time_secs = timestamp_info.hour as i64 * 3600
+ timestamp_info.minute as i64 * 60
+ timestamp_info.second as i64;
Expand Down Expand Up @@ -1864,11 +1848,17 @@ fn date_parser(date_str: &str, eval_mode: EvalMode) -> SparkResult<Option<i32>>
byte.is_ascii_whitespace() || byte.is_ascii_control()
}

/// Decodes a run of ASCII digits, or `None` if any byte is not a digit.
fn decode_digits(bytes: &[u8]) -> Option<i64> {
bytes.iter().try_fold(0i64, |acc, b| {
b.is_ascii_digit().then(|| acc * 10 + (b - b'0') as i64)
})
}

fn is_valid_digits(segment: i32, digits: usize) -> bool {
// NaiveDate is bounded to [-262142, 262142] (6 digits). We allow up to 7 digits to support
// leading-zero year strings like "0002020" (= year 2020), matching Spark's
// isValidDigits. Values outside the bounds are caught by an explicit bounds
// check below.
// Years are bounded to [-262143, 262142] by the explicit range check below. We allow up
// to 7 digits to support leading-zero year strings like "0002020" (= year 2020),
// matching Spark's isValidDigits.
let max_digits_year = 7;
// year (segment 0) can be between 4 to 7 digits,
// month and day (segment 1 and 2) can be between 1 to 2 digits
Expand All @@ -1893,12 +1883,6 @@ fn date_parser(date_str: &str, eval_mode: EvalMode) -> SparkResult<Option<i32>>
return return_result(date_str, eval_mode);
}

//values of date segments year, month and day defaulting to 1
let mut date_segments = [1, 1, 1];
let mut sign = 1;
let mut current_segment = 0;
let mut current_segment_value = Wrapping(0);
let mut current_segment_digits = 0;
let bytes = date_str.as_bytes();

let mut j = get_trimmed_start(bytes);
Expand All @@ -1908,6 +1892,29 @@ fn date_parser(date_str: &str, eval_mode: EvalMode) -> SparkResult<Option<i32>>
return return_result(date_str, eval_mode);
}

// Fast path for the canonical `yyyy-mm-dd` form. A 4-digit year is always inside the
// range checked below, so the only way this can fail is an invalid calendar date, which
// is a null in every eval mode rather than an ANSI error. Any other shape (including a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment at the fast path (native/spark-expr/src/conversion_funcs/string.rs:1897 in the diff) says an invalid calendar date "is a null in every eval mode rather than an ANSI error." That is not what Spark does. stringToDate returns None for an invalid calendar date, and stringToDateAnsi (SparkDateTimeUtils.scala:398) turns any None into invalidInputInCastToDatetimeError, so Spark ANSI throws on 2020-02-30. Comet's existing code already returns Ok(None) here (the caller at string.rs:716-720 treats Ok(None) as null in every mode), so this PR introduces no regression, but the comment states a false fact about Spark and will mislead the next reader.

Suggested change: describe Comet's actual behavior, not an incorrect Spark claim:

// Fast path for the canonical `yyyy-mm-dd` form. A 4-digit year is always inside the
// range checked below, so the only way this fails is an invalid calendar date. That
// path already returns null in Comet for every eval mode (the caller maps Ok(None) to
// null), matching the general parser here. Any other shape, including a leading sign
// that makes the first byte a non-digit, falls through to the general parser.

// leading sign, which makes the first byte a non-digit) falls through to the general
// parser below.
let trimmed = &bytes[j..str_end_trimmed];
if trimmed.len() == 10 && trimmed[4] == b'-' && trimmed[7] == b'-' {
if let (Some(year), Some(month), Some(day)) = (
decode_digits(&trimmed[..4]),
decode_digits(&trimmed[5..7]),
decode_digits(&trimmed[8..10]),
) {
return Ok(ymd_to_epoch_day(year, month, day).map(|days| days as i32));
}
}

//values of date segments year, month and day defaulting to 1
let mut date_segments = [1, 1, 1];
let mut sign = 1;
let mut current_segment = 0;
let mut current_segment_value = Wrapping(0);
let mut current_segment_digits = 0;

// assign a sign to the date; both '-' and '+' are accepted (Spark stringToDate line 357-360)
if bytes[j] == b'-' {
sign = -1;
Expand Down Expand Up @@ -1960,15 +1967,12 @@ fn date_parser(date_str: &str, eval_mode: EvalMode) -> SparkResult<Option<i32>>
return Ok(None);
}

match NaiveDate::from_ymd_opt(year, date_segments[1] as u32, date_segments[2] as u32) {
Some(date) => {
let duration_since_epoch = date
.signed_duration_since(DateTime::UNIX_EPOCH.naive_utc().date())
.num_days();
Ok(Some(duration_since_epoch.to_i32().unwrap()))
}
None => Ok(None),
}
Ok(ymd_to_epoch_day(
year as i64,
date_segments[1] as i64,
date_segments[2] as i64,
)
.map(|days| days as i32))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The general path now ends with .map(|days| days as i32) (native/spark-expr/src/conversion_funcs/string.rs:1975 in the diff) replacing duration_since_epoch.to_i32().unwrap(). The year range check at string.rs:1966 guarantees the value fits in i32, so both are correct today, but as i32 would silently wrap if that invariant were ever weakened, whereas .unwrap() failed loudly. Since the invariant is enforced two lines up, prefer a debug_assert! over reintroducing the checked conversion so the fast path stays branch-free:

Ok(ymd_to_epoch_day(year as i64, date_segments[1] as i64, date_segments[2] as i64).map(|days| {
    debug_assert!(i32::try_from(days).is_ok(), "epoch day {days} out of i32 range for year {year}");
    days as i32
}))

The fast path can keep the bare days as i32 since a 4-digit year can never overflow.

}

#[cfg(test)]
Expand Down
Loading