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
54 changes: 54 additions & 0 deletions encodings/fsst/benches/fsst_like.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ impl Dataset {
}
}

/// Suffixes that actually occur in each generator, so the arm exercises both the
/// reject and the accept path. Match rates: rare 0%, email 9.1%, urls 9.5%,
/// cb 10.3%, path 10.6%, log 16.8%, json 100%.
fn suffix_pattern(&self) -> &'static str {
match self {
Self::Urls => "%index.html",
Self::Cb => "%reviews",
Self::Log => "%bot.html)\"",
Self::Json => "%}",
Self::Path => "%main.rs",
Self::Email => "%gmail.com",
Self::Rare => "%xyzzy",
}
}

fn contains_pattern(&self) -> &'static str {
match self {
Self::Urls => "%google%",
Expand Down Expand Up @@ -128,6 +143,29 @@ fn bench_like(bencher: Bencher, fsst: &FSSTArray, pattern: &str) {
});
}

/// The decompress-then-compare path the kernel falls back to when a pattern cannot be
/// pushed down, for comparison against the arms above.
///
/// Canonicalizing first is what the fallback does: for a constant pattern the scalar fn
/// runs `execute::<VarBinViewArray>` on the haystack and then evaluates over the views.
/// Doing it here keeps both legs in one bench binary, so the comparison needs no source
/// edit to reproduce.
fn bench_like_canonicalize(bencher: Bencher, fsst: &FSSTArray, pattern: &str) {
let len = fsst.len();
let arr = fsst.clone().into_array();
let pattern = ConstantArray::new(pattern, len).into_array();
bencher
.with_inputs(|| SESSION.create_execution_ctx())
.bench_refs(|ctx| {
let canonical = arr.clone().execute::<Canonical>(ctx).unwrap().into_array();
Like::try_new(canonical, pattern.clone(), LikeOptions::default())
.unwrap()
.into_array()
.execute::<Canonical>(ctx)
.unwrap()
});
}

#[divan::bench(args = [
Dataset::Urls, Dataset::Cb, Dataset::Log, Dataset::Json,
Dataset::Path, Dataset::Email, Dataset::Rare,
Expand All @@ -143,3 +181,19 @@ fn fsst_prefix(bencher: Bencher, dataset: &Dataset) {
fn fsst_contains(bencher: Bencher, dataset: &Dataset) {
bench_like(bencher, dataset.fsst_array(), dataset.contains_pattern());
}

#[divan::bench(args = [
Dataset::Urls, Dataset::Cb, Dataset::Log, Dataset::Json,
Dataset::Path, Dataset::Email, Dataset::Rare,
])]
fn fsst_suffix(bencher: Bencher, dataset: &Dataset) {
bench_like(bencher, dataset.fsst_array(), dataset.suffix_pattern());
}

#[divan::bench(args = [
Dataset::Urls, Dataset::Cb, Dataset::Log, Dataset::Json,
Dataset::Path, Dataset::Email, Dataset::Rare,
])]
fn fsst_suffix_canonicalize(bencher: Bencher, dataset: &Dataset) {
bench_like_canonicalize(bencher, dataset.fsst_array(), dataset.suffix_pattern());
}
43 changes: 40 additions & 3 deletions encodings/fsst/src/compute/like.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ mod tests {
use vortex_array::scalar_fn::fns::like::Like;
use vortex_array::scalar_fn::fns::like::LikeKernel;
use vortex_array::scalar_fn::fns::like::LikeOptions;
use vortex_error::VortexExpect;
use vortex_error::VortexResult;
use vortex_session::VortexSession;

Expand Down Expand Up @@ -282,6 +283,42 @@ mod tests {
Ok(())
}

/// `%suffix` must be evaluated by the kernel, not handed back for
/// decompression. Asserting the result is `Some` is what distinguishes
/// pushdown from the fallback path — the boolean answer is the same either way.
#[test]
fn test_like_kernel_pushes_down_suffix() -> VortexResult<()> {
let fsst = make_fsst(
&[Some("abc"), Some("xabc"), Some("abcx")],
Nullability::NonNullable,
);
let mut ctx = SESSION.create_execution_ctx();
let fsst_v = fsst.as_view();

let pattern = ConstantArray::new("%abc", fsst.len()).into_array();
let result =
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?
.vortex_expect("suffix pattern must be pushed down, not fall back");
let expected = BoolArray::from_iter([true, true, false]);
assert_arrays_eq!(&result, &expected, &mut ctx);

// Negated form goes through the same matcher.
let result = <FSST as LikeKernel>::like(
fsst_v,
&pattern,
LikeOptions {
negated: true,
case_insensitive: false,
},
&mut ctx,
)?
.vortex_expect("negated suffix pattern must be pushed down");
let expected = BoolArray::from_iter([false, false, true]);
assert_arrays_eq!(&result, &expected, &mut ctx);

Ok(())
}

/// Patterns we can't handle should return `None` (fall back).
#[test]
fn test_like_kernel_falls_back_for_complex_pattern() -> VortexResult<()> {
Expand All @@ -304,11 +341,11 @@ mod tests {
let result = <FSST as LikeKernel>::like(fsst_v, &pattern, opts, &mut ctx)?;
assert!(result.is_none(), "ilike should fall back");

// Suffix patterns are still unsupported, even when the suffix is an escaped literal.
let pattern = ConstantArray::new(r"%\%", fsst.len()).into_array();
// A `%` in the middle is none of prefix, contains or suffix.
let pattern = ConstantArray::new("a%b", fsst.len()).into_array();
let result =
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
assert!(result.is_none(), "escaped suffix pattern should fall back");
assert!(result.is_none(), "mid-pattern % should fall back");

Ok(())
}
Expand Down
90 changes: 77 additions & 13 deletions encodings/fsst/src/dfa/mod.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! # FSST LIKE Pushdown via DFA Construction
//! # FSST LIKE Pushdown
//!
//! This module implements DFA-based pattern matching directly on FSST-compressed
//! strings, without decompressing them. It handles two pattern shapes:
//! This module implements pattern matching directly on FSST-compressed strings,
//! without decompressing them. It handles three pattern shapes:
//!
//! - **Prefix**: `'prefix%'` — matches strings starting with a literal prefix.
//! - **Contains**: `'%needle%'` — matches strings containing a literal substring.
//! - **Suffix**: `'%suffix'` — matches strings ending with a literal suffix.
//!
//! Pushdown is intentionally conservative. If the pattern shape is unsupported,
//! or if the pattern exceeds the DFA's representable state space, construction
//! returns `None` and the caller must fall back to ordinary decompression-based
//! LIKE evaluation.
//!
//! TODO(joe): suffix (`'%suffix'`) pushdown. Two approaches:
//! - **Forward DFA**: use a non-sticky accept state with KMP fallback transitions,
//! check `state == accept` after processing all codes. Branchless and vectorizable.
//! - **Backward scan**: walk the compressed code stream in reverse, comparing symbol
//! bytes from the end. Simpler, no DFA construction, but requires reverse parsing
//! of the FSST escape mechanism.
//! Prefix and contains are DFAs over the code stream, described below. Suffix is not:
//! a forward DFA cannot stop early, because a suffix match is only decided at the last
//! code, and that measured slower than decompressing. [`suffix::SuffixMatcher`] takes the
//! other route the original TODO sketched and walks the code stream backward from each
//! row's end; the reverse parsing that route needs turns out to be local, and that module
//! explains why.
//!
//! ## Background: FSST Encoding
//!
Expand Down Expand Up @@ -108,7 +109,7 @@
//!
//! ## State-Space Limits
//!
//! The public behavior is shaped by two implementation limits, both measured in
//! The public behavior is shaped by three implementation limits, all measured in
//! pattern **bytes** rather than Unicode scalar values:
//!
//! - `prefix%` pushdown is limited to **253 bytes**. The flat prefix DFA uses
Expand All @@ -117,12 +118,16 @@
//! - `%needle%` pushdown is limited to **254 bytes**. The contains DFA stores
//! states in `u8`, so it needs room for every match-progress state plus both
//! the accept state and the escape sentinel.
//! - `%suffix` pushdown is limited to **254 bytes**. The tail matcher compares
//! bytes rather than holding states, so this bound only keeps it in step with
//! the other two.
//!
//! Patterns beyond those limits are still valid LIKE patterns; they simply do
//! not use FSST pushdown and must be evaluated through the fallback path.

mod flat_contains;
mod prefix;
mod suffix;
#[cfg(test)]
mod tests;

Expand All @@ -132,6 +137,7 @@ use flat_contains::FlatContainsDfa;
use fsst::ESCAPE_CODE;
use fsst::Symbol;
use prefix::FlatPrefixDfa;
use suffix::SuffixMatcher;
use vortex_buffer::BitBuffer;
use vortex_error::VortexExpect;
use vortex_error::VortexResult;
Expand All @@ -154,14 +160,15 @@ enum MatcherInner {
MatchAll,
Prefix(FlatPrefixDfa),
Contains(FlatContainsDfa),
Suffix(SuffixMatcher),
}

impl FsstMatcher {
/// Try to build a matcher for the given LIKE pattern.
///
/// Returns `Ok(None)` if the pattern shape is not supported for pushdown
/// (e.g. `_` wildcards, multiple non-bookend `%`, `prefix%` longer than
/// 253 bytes, or `%needle%` longer than 254 bytes).
/// 253 bytes, or `%needle%`/`%suffix` longer than 254 bytes).
pub(crate) fn try_new(
symbols: &[Symbol],
symbol_lengths: &[u8],
Expand All @@ -172,7 +179,9 @@ impl FsstMatcher {
};

let inner = match like_kind {
LikeKind::Prefix(pattern) | LikeKind::Contains(pattern) if pattern.is_empty() => {
LikeKind::Prefix(pattern) | LikeKind::Contains(pattern) | LikeKind::Suffix(pattern)
if pattern.is_empty() =>
{
MatcherInner::MatchAll
}
LikeKind::Prefix(prefix) => {
Expand All @@ -195,6 +204,16 @@ impl FsstMatcher {
needle.as_ref(),
)?)
}
LikeKind::Suffix(suffix) => {
if suffix.len() > SuffixMatcher::MAX_SUFFIX_LEN {
return Ok(None);
}
MatcherInner::Suffix(SuffixMatcher::new(
symbols,
symbol_lengths,
suffix.as_ref(),
)?)
}
};

Ok(Some(Self { inner }))
Expand All @@ -206,6 +225,7 @@ impl FsstMatcher {
MatcherInner::MatchAll => true,
MatcherInner::Prefix(dfa) => dfa.matches(codes),
MatcherInner::Contains(dfa) => dfa.matches(codes),
MatcherInner::Suffix(matcher) => matcher.matches(codes),
}
}
}
Expand All @@ -216,11 +236,15 @@ enum LikeKind<'a> {
Prefix(Cow<'a, [u8]>),
/// `%needle%`
Contains(Cow<'a, [u8]>),
/// `%suffix`
Suffix(Cow<'a, [u8]>),
}

impl<'a> LikeKind<'a> {
fn parse(pattern: &'a [u8]) -> Option<Self> {
Self::parse_prefix(pattern).or_else(|| Self::parse_contains(pattern))
Self::parse_prefix(pattern)
.or_else(|| Self::parse_contains(pattern))
.or_else(|| Self::parse_suffix(pattern))
}

fn parse_prefix(pattern: &'a [u8]) -> Option<Self> {
Expand All @@ -235,6 +259,46 @@ impl<'a> LikeKind<'a> {
Self::parse_literal_until_final_percent(pattern, 1).map(LikeKind::Contains)
}

fn parse_suffix(pattern: &'a [u8]) -> Option<Self> {
if !pattern.starts_with(b"%") {
return None;
}

Self::parse_literal_to_end(pattern, 1).map(LikeKind::Suffix)
}

/// Parse `pattern[literal_start..]` as a literal running to the end of the
/// pattern. Returns `None` if `_` or `%` is encountered, since either means
/// the tail is not a plain literal.
fn parse_literal_to_end(pattern: &'a [u8], literal_start: usize) -> Option<Cow<'a, [u8]>> {
let mut literal: Option<Vec<u8>> = None;
let mut idx = literal_start;
while idx < pattern.len() {
match pattern[idx] {
b'\\' => {
// Trailing `\` is treated as a literal backslash.
let escaped = pattern.get(idx + 1).copied().unwrap_or(b'\\');
literal
.get_or_insert_with(|| pattern[literal_start..idx].to_vec())
.push(escaped);
idx = (idx + 2).min(pattern.len());
}
b'%' | b'_' => return None,
byte => {
// No-op on the borrowed path; only push once we've started copying.
if let Some(literal) = &mut literal {
literal.push(byte);
}
idx += 1;
}
}
}
Some(match literal {
Some(buf) => Cow::Owned(buf),
None => Cow::Borrowed(&pattern[literal_start..]),
})
}

/// Parse `pattern[literal_start..]` as a literal terminated by a single
/// trailing `%`. Returns `None` if `_` or a non-final `%` is encountered.
///
Expand Down
Loading