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
11 changes: 7 additions & 4 deletions native/spark-expr/benches/regexp_extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use arrow::array::ArrayRef;
use criterion::{criterion_group, criterion_main, Criterion};
use datafusion::common::ScalarValue;
use datafusion::physical_plan::ColumnarValue;
use datafusion_comet_spark_expr::spark_regexp_extract;
use datafusion_comet_spark_expr::{spark_regexp_extract, PatternCache};
use std::hint::black_box;
use std::sync::Arc;

Expand All @@ -47,7 +47,8 @@ fn criterion_benchmark(c: &mut Criterion) {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(r"(\d+)-(\d+)".to_string()))),
ColumnarValue::Scalar(ScalarValue::Int32(Some(1))),
];
b.iter(|| black_box(spark_regexp_extract(black_box(&args)).unwrap()))
let cache = PatternCache::new();
b.iter(|| black_box(spark_regexp_extract(black_box(&args), &cache).unwrap()))
});

// Extract the whole match (group 0).
Expand All @@ -57,7 +58,8 @@ fn criterion_benchmark(c: &mut Criterion) {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(r"(\d+)-(\d+)".to_string()))),
ColumnarValue::Scalar(ScalarValue::Int32(Some(0))),
];
b.iter(|| black_box(spark_regexp_extract(black_box(&args)).unwrap()))
let cache = PatternCache::new();
b.iter(|| black_box(spark_regexp_extract(black_box(&args), &cache).unwrap()))
});

// Extract the second capture group.
Expand All @@ -67,7 +69,8 @@ fn criterion_benchmark(c: &mut Criterion) {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(r"(\d+)-(\d+)".to_string()))),
ColumnarValue::Scalar(ScalarValue::Int32(Some(2))),
];
b.iter(|| black_box(spark_regexp_extract(black_box(&args)).unwrap()))
let cache = PatternCache::new();
b.iter(|| black_box(spark_regexp_extract(black_box(&args), &cache).unwrap()))
});
}

Expand Down
50 changes: 48 additions & 2 deletions native/spark-expr/benches/regexp_extract_all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use datafusion::common::ScalarValue;
use datafusion::physical_plan::ColumnarValue;
use datafusion_comet_spark_expr::spark_regexp_extract_all;
use datafusion_comet_spark_expr::{spark_regexp_extract_all, PatternCache};
use std::hint::black_box;

#[path = "common/mod.rs"]
Expand All @@ -29,6 +29,17 @@ const INPUT: &str =
"datafusion has datafusion-python, datafusion-comet, datafusion-java as sub projects";
const PATTERN: &str = r"(\w+)-(\w+)";

/// Short rows with several matches each, so the per-match cost dominates.
const DIGITS_INPUT: &str = "123-456-789-123";
const DIGITS_PATTERN: &str = r"(\d+)";
const DIGITS_ROWS: usize = 8_192;

/// 8 KB rows made of short runs of `a` separated by a single `b`, so a single row carries
/// thousands of matches and the cost of walking a long haystack dominates.
const LONG_ROW_BYTES: usize = 8_192;
const LONG_PATTERN: &str = r"(a+)";
const LONG_ROWS: usize = 512;

fn criterion_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("spark_regexp_extract_all");
for rows in ROW_COUNTS {
Expand All @@ -41,10 +52,45 @@ fn criterion_benchmark(c: &mut Criterion) {
group.bench_with_input(
BenchmarkId::from_parameter(format!("{rows}/{tag}")),
&args,
|b, args| b.iter(|| black_box(spark_regexp_extract_all(black_box(args)).unwrap())),
|b, args| {
// One cache per benchmark input mirrors one cache per planned expression.
let cache = PatternCache::new();
b.iter(|| black_box(spark_regexp_extract_all(black_box(args), &cache).unwrap()))
},
);
}
}

let digits_args = vec![
ColumnarValue::Array(string_array(DIGITS_ROWS, 0.0, |_| DIGITS_INPUT.to_string())),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(DIGITS_PATTERN.to_string()))),
ColumnarValue::Scalar(ScalarValue::Int32(Some(1))),
];
group.bench_with_input(
BenchmarkId::from_parameter(format!("digits/{DIGITS_ROWS}")),
&digits_args,
|b, args| {
// One cache per benchmark input mirrors one cache per planned expression.
let cache = PatternCache::new();
b.iter(|| black_box(spark_regexp_extract_all(black_box(args), &cache).unwrap()))
},
);

let long_row = "aaab".repeat(LONG_ROW_BYTES / 4);
let long_args = vec![
ColumnarValue::Array(string_array(LONG_ROWS, 0.0, |_| long_row.clone())),
ColumnarValue::Scalar(ScalarValue::Utf8(Some(LONG_PATTERN.to_string()))),
ColumnarValue::Scalar(ScalarValue::Int32(Some(1))),
];
group.bench_with_input(
BenchmarkId::from_parameter(format!("long_8kb/{LONG_ROWS}")),
&long_args,
|b, args| {
// One cache per benchmark input mirrors one cache per planned expression.
let cache = PatternCache::new();
b.iter(|| black_box(spark_regexp_extract_all(black_box(args), &cache).unwrap()))
},
);
group.finish();
}

Expand Down
8 changes: 6 additions & 2 deletions native/spark-expr/benches/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use datafusion::common::ScalarValue;
use datafusion::physical_plan::ColumnarValue;
use datafusion_comet_spark_expr::{spark_split, spark_split_sql};
use datafusion_comet_spark_expr::{spark_split, spark_split_sql, PatternCache};
use std::hint::black_box;

#[path = "common/mod.rs"]
Expand All @@ -40,7 +40,11 @@ fn criterion_benchmark(c: &mut Criterion) {
split_group.bench_with_input(
BenchmarkId::from_parameter(format!("{rows}/{tag}")),
&args,
|b, args| b.iter(|| black_box(spark_split(black_box(args)).unwrap())),
|b, args| {
// One cache per benchmark input mirrors one cache per planned expression.
let cache = PatternCache::new();
b.iter(|| black_box(spark_split(black_box(args), &cache).unwrap()))
},
);
}
}
Expand Down
14 changes: 11 additions & 3 deletions native/spark-expr/src/comet_scalar_funcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,19 +223,27 @@ pub fn create_comet_physical_fun_with_eval_mode(
make_comet_scalar_udf!("unbase64", func, without data_type)
}
"split" => {
let func = Arc::new(crate::string_funcs::spark_split);
// One cache per planned expression: the pattern is a literal, so the regex
// compiles on the first batch and is reused for the rest.
let cache = crate::string_funcs::PatternCache::new();
let func: ScalarFunctionImplementation =
Arc::new(move |args| crate::string_funcs::spark_split(args, &cache));
make_comet_scalar_udf!("split", func, without data_type)
}
"split_sql" => {
let func = Arc::new(crate::string_funcs::spark_split_sql);
make_comet_scalar_udf!("split_sql", func, without data_type)
}
"regexp_extract" => {
let func = Arc::new(crate::string_funcs::spark_regexp_extract);
let cache = crate::string_funcs::PatternCache::new();
let func: ScalarFunctionImplementation =
Arc::new(move |args| crate::string_funcs::spark_regexp_extract(args, &cache));
make_comet_scalar_udf!("regexp_extract", func, without data_type)
}
"regexp_extract_all" => {
let func = Arc::new(crate::string_funcs::spark_regexp_extract_all);
let cache = crate::string_funcs::PatternCache::new();
let func: ScalarFunctionImplementation =
Arc::new(move |args| crate::string_funcs::spark_regexp_extract_all(args, &cache));
make_comet_scalar_udf!("regexp_extract_all", func, without data_type)
}
"get_json_object" => {
Expand Down
2 changes: 2 additions & 0 deletions native/spark-expr/src/string_funcs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod base64;
mod contains;
mod get_json_object;
mod levenshtein;
mod pattern_cache;
mod regexp_extract;
mod regexp_extract_all;
mod regexp_extract_common;
Expand All @@ -29,6 +30,7 @@ pub use base64::spark_base64;
pub use contains::SparkContains;
pub use get_json_object::spark_get_json_object;
pub use levenshtein::spark_levenshtein;
pub use pattern_cache::PatternCache;
pub use regexp_extract::spark_regexp_extract;
pub use regexp_extract_all::spark_regexp_extract_all;
pub use split::{spark_split, spark_split_sql};
Expand Down
106 changes: 106 additions & 0 deletions native/spark-expr/src/string_funcs/pattern_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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 regex::Regex;
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, PoisonError};

/// Per-expression cache for a compiled regex. The regexp scalar functions receive their
/// pattern as a per-batch scalar argument even though the serde only plans them with literal
/// patterns, so without this every batch would pay a full regex compile. One slot is enough:
/// a given expression instance sees a single pattern for the lifetime of its plan.
pub struct PatternCache {
cached: Mutex<Option<(String, Regex)>>,
#[cfg(test)]
compile_count: AtomicUsize,
}

impl PatternCache {
pub fn new() -> Self {
Self {
cached: Mutex::new(None),
#[cfg(test)]
compile_count: AtomicUsize::new(0),
}
}

/// Return the compiled regex for `pattern`, compiling and caching it only when the
/// pattern differs from the previously cached one. `Regex` clones share the compiled
/// program, so handing out clones is cheap.
pub fn get_or_compile(&self, pattern: &str) -> Result<Regex, regex::Error> {
// A poisoned lock only means another thread panicked mid-update; the slot is either
// intact or about to be refilled, so recover rather than propagate the panic.
let mut slot = self.cached.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((cached_pattern, regex)) = slot.as_ref() {
if cached_pattern == pattern {
return Ok(regex.clone());
}
}
#[cfg(test)]
self.compile_count.fetch_add(1, Ordering::Relaxed);
let regex = Regex::new(pattern)?;
*slot = Some((pattern.to_string(), regex.clone()));
Ok(regex)
}

/// Number of times a regex was actually compiled, for asserting the cache works.
#[cfg(test)]
pub(crate) fn compile_count(&self) -> usize {
self.compile_count.load(Ordering::Relaxed)
}
}

impl Default for PatternCache {
fn default() -> Self {
Self::new()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn compiles_once_for_repeated_pattern() {
let cache = PatternCache::new();
for _ in 0..5 {
let re = cache.get_or_compile(r"(\d+)-(\d+)").unwrap();
assert!(re.is_match("12-34"));
}
assert_eq!(cache.compile_count(), 1);
}

#[test]
fn recompiles_when_pattern_changes() {
let cache = PatternCache::new();
cache.get_or_compile(r"\d+").unwrap();
cache.get_or_compile(r"[a-z]+").unwrap();
// Switching back replaces the single slot again.
cache.get_or_compile(r"\d+").unwrap();
assert_eq!(cache.compile_count(), 3);
}

#[test]
fn invalid_pattern_errors_and_is_not_cached() {
let cache = PatternCache::new();
assert!(cache.get_or_compile(r"(unclosed").is_err());
// A later valid pattern still works.
let re = cache.get_or_compile(r"ok").unwrap();
assert!(re.is_match("ok"));
}
}
46 changes: 39 additions & 7 deletions native/spark-expr/src/string_funcs/regexp_extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use datafusion::logical_expr::ColumnarValue;
use regex::Regex;
use std::sync::Arc;

use super::pattern_cache::PatternCache;
use super::regexp_extract_common::{parse_args, ParsedArgs};

/// Spark-compatible `regexp_extract(subject, pattern, idx)`.
Expand All @@ -37,8 +38,11 @@ use super::regexp_extract_common::{parse_args, ParsedArgs};
///
/// Note: this uses the Rust `regex` crate, whose syntax differs from Java's regex engine in
/// some ways. The expression is therefore reported as Incompatible.
pub fn spark_regexp_extract(args: &[ColumnarValue]) -> DataFusionResult<ColumnarValue> {
let (regex, group_idx, subject) = match parse_args("regexp_extract", args)? {
pub fn spark_regexp_extract(
args: &[ColumnarValue],
regex_cache: &PatternCache,
) -> DataFusionResult<ColumnarValue> {
let (regex, group_idx, subject) = match parse_args("regexp_extract", args, regex_cache)? {
ParsedArgs::Parsed {
regex,
group_idx,
Expand Down Expand Up @@ -132,8 +136,12 @@ mod tests {
use arrow::array::{LargeStringArray, StringArray};
use datafusion::common::DataFusionError;

fn call_raw(args: &[ColumnarValue]) -> DataFusionResult<ColumnarValue> {
spark_regexp_extract(args, &PatternCache::new())
}

fn run(args: Vec<ColumnarValue>) -> DataFusionResult<Vec<Option<String>>> {
let result = spark_regexp_extract(&args)?;
let result = call_raw(&args)?;
match result {
ColumnarValue::Array(arr) => {
let s = arr
Expand Down Expand Up @@ -240,7 +248,7 @@ mod tests {

#[test]
fn group_index_out_of_range_errors() {
let err = spark_regexp_extract(&[array(vec![Some("abc")]), pattern(r"(a)(b)"), idx(3)])
let err = call_raw(&[array(vec![Some("abc")]), pattern(r"(a)(b)"), idx(3)])
.err()
.unwrap();
let msg = err.to_string();
Expand All @@ -250,7 +258,7 @@ mod tests {

#[test]
fn negative_index_errors() {
let err = spark_regexp_extract(&[array(vec![Some("abc")]), pattern(r"(a)"), idx(-1)])
let err = call_raw(&[array(vec![Some("abc")]), pattern(r"(a)"), idx(-1)])
.err()
.unwrap();
let msg = err.to_string();
Expand All @@ -260,12 +268,36 @@ mod tests {

#[test]
fn invalid_regex_errors() {
let err = spark_regexp_extract(&[array(vec![Some("abc")]), pattern(r"(unclosed"), idx(0)])
let err = call_raw(&[array(vec![Some("abc")]), pattern(r"(unclosed"), idx(0)])
.err()
.unwrap();
assert!(err.to_string().contains("`regexp`"));
}

/// One expression evaluates many batches; the pattern must compile once and results
/// must stay correct on every batch.
#[test]
fn compiles_regex_once_across_batches() {
let cache = PatternCache::new();
for batch in 0..4 {
let subject = format!("{batch}00-{batch}99");
let expected = format!("{batch}00");
let result = spark_regexp_extract(
&[array(vec![Some(&subject)]), pattern(r"(\d+)-(\d+)"), idx(1)],
&cache,
)
.unwrap();
match result {
ColumnarValue::Array(arr) => {
let s = arr.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(s.value(0), expected);
}
other => panic!("unexpected result: {other:?}"),
}
}
assert_eq!(cache.compile_count(), 1);
}

/// `LargeUtf8` subject must still produce a `StringArray` (i32 offsets) so the result type
/// matches Spark's `RegExpExtract.dataType` = `StringType`. Regression for the bug where
/// `extract_array::<i64>` used to build a `LargeStringArray` and trip a type mismatch.
Expand All @@ -276,7 +308,7 @@ mod tests {
None,
Some("foo-bar"),
])));
let result = spark_regexp_extract(&[array, pattern(r"(\d+)-(\d+)"), idx(1)]).unwrap();
let result = call_raw(&[array, pattern(r"(\d+)-(\d+)"), idx(1)]).unwrap();
match result {
ColumnarValue::Array(arr) => {
arr.as_any()
Expand Down
Loading