From d23737b44d275e269893b7adefdf13c358477ad9 Mon Sep 17 00:00:00 2001 From: Ishaan Maheshwari Date: Sun, 11 Oct 2026 05:34:50 +0530 Subject: [PATCH 1/2] GH-53159: [C++][Compute] Cache substring and regex matchers across invocations in scalar string kernels --- .../compute/kernels/scalar_string_ascii.cc | 293 +++++++++++++----- .../compute/kernels/scalar_string_test.cc | 73 +++++ 2 files changed, 284 insertions(+), 82 deletions(-) diff --git a/cpp/src/arrow/compute/kernels/scalar_string_ascii.cc b/cpp/src/arrow/compute/kernels/scalar_string_ascii.cc index 8e7b626837fe..2fa7b052d4fd 100644 --- a/cpp/src/arrow/compute/kernels/scalar_string_ascii.cc +++ b/cpp/src/arrow/compute/kernels/scalar_string_ascii.cc @@ -17,9 +17,11 @@ #include #include +#include #include #include #include +#include #include #include "arrow/array/builder_nested.h" @@ -1216,8 +1218,6 @@ void StringBoolTransform(KernelContext* ctx, const ExecSpan& batch, } } -using MatchSubstringState = OptionsWrapper; - // This is an implementation of the Knuth-Morris-Pratt algorithm struct PlainSubstringMatcher { const MatchSubstringOptions& options_; @@ -1308,7 +1308,7 @@ struct PlainEndsWithMatcher { #ifdef ARROW_WITH_RE2 struct RegexSubstringMatcher { - const MatchSubstringOptions& options_; + MatchSubstringOptions options_; const RE2 regex_match_; static Result> Make( @@ -1318,11 +1318,11 @@ struct RegexSubstringMatcher { return matcher; } - explicit RegexSubstringMatcher(const MatchSubstringOptions& options, - bool is_utf8 = true, bool literal = false) - : options_(options), + explicit RegexSubstringMatcher(MatchSubstringOptions options, bool is_utf8 = true, + bool literal = false) + : options_(std::move(options)), regex_match_(options_.pattern, - MakeRE2Options(is_utf8, options.ignore_case, literal)) {} + MakeRE2Options(is_utf8, options_.ignore_case, literal)) {} bool Match(std::string_view current) const { auto piece = re2::StringPiece(current.data(), current.length()); @@ -1331,6 +1331,72 @@ struct RegexSubstringMatcher { }; #endif +struct MatchSubstringState : public KernelState { + MatchSubstringOptions options; + + std::mutex mutex; + std::atomic plain_substring_matcher{nullptr}; + std::unique_ptr plain_substring_matcher_holder; + + std::atomic plain_starts_with_matcher{nullptr}; + std::unique_ptr plain_starts_with_matcher_holder; + + std::atomic plain_ends_with_matcher{nullptr}; + std::unique_ptr plain_ends_with_matcher_holder; + +#ifdef ARROW_WITH_RE2 + std::atomic regex_substring_matcher{nullptr}; + std::unique_ptr regex_substring_matcher_holder; + + enum class LikeKind { kNone, kSubstring, kStartsWith, kEndsWith, kRegex }; + std::atomic like_kind{LikeKind::kNone}; + std::unique_ptr like_state; +#endif + + explicit MatchSubstringState(MatchSubstringOptions options) + : options(std::move(options)) {} + + template + Result GetOrInitMatcher(std::atomic& atomic_matcher, + std::unique_ptr& holder, Factory&& factory) { + Matcher* matcher = atomic_matcher.load(std::memory_order_acquire); + if (ARROW_PREDICT_TRUE(matcher != nullptr)) { + return matcher; + } + std::lock_guard lock(mutex); + matcher = atomic_matcher.load(std::memory_order_relaxed); + if (matcher != nullptr) { + return matcher; + } + ARROW_ASSIGN_OR_RAISE(holder, factory()); + matcher = holder.get(); + atomic_matcher.store(matcher, std::memory_order_release); + return matcher; + } + + static Result> Init(KernelContext* ctx, + const KernelInitArgs& args) { + if (auto options = static_cast(args.options)) { + return std::make_unique(*options); + } + + return Status::Invalid( + "Attempted to initialize KernelState from null FunctionOptions"); + } + + static const MatchSubstringOptions& Get(const KernelState& state) { + return ::arrow::internal::checked_cast(state).options; + } + + static const MatchSubstringOptions& Get(KernelContext* ctx) { + return Get(*ctx->state()); + } + + static MatchSubstringState* GetState(KernelContext* ctx) { + return ::arrow::internal::checked_cast(ctx->state()); + } +}; + template struct MatchSubstringImpl { using offset_type = typename Type::offset_type; @@ -1361,7 +1427,6 @@ struct MatchSubstringImpl { template struct MatchSubstring { static Status Exec(KernelContext* ctx, const ExecSpan& batch, ExecResult* out) { - // TODO Cache matcher across invocations (for regex compilation) ARROW_ASSIGN_OR_RAISE(auto matcher, Matcher::Make(MatchSubstringState::Get(ctx))); return MatchSubstringImpl::Exec(ctx, batch, out, matcher.get()); } @@ -1371,12 +1436,19 @@ struct MatchSubstring { template struct MatchSubstring { static Status Exec(KernelContext* ctx, const ExecSpan& batch, ExecResult* out) { - // TODO Cache matcher across invocations (for regex compilation) + return ExecWithState(ctx, batch, out, MatchSubstringState::GetState(ctx)); + } + + static Status ExecWithState(KernelContext* ctx, const ExecSpan& batch, ExecResult* out, + MatchSubstringState* state) { const bool is_utf8 = is_string_or_string_view(batch[0].type()->id()); - ARROW_ASSIGN_OR_RAISE(auto matcher, RegexSubstringMatcher::Make( - MatchSubstringState::Get(ctx), is_utf8)); + ARROW_ASSIGN_OR_RAISE( + auto* matcher, + state->GetOrInitMatcher( + state->regex_substring_matcher, state->regex_substring_matcher_holder, + [&]() { return RegexSubstringMatcher::Make(state->options, is_utf8); })); return MatchSubstringImpl::Exec(ctx, batch, out, - matcher.get()); + matcher); } }; #endif @@ -1384,67 +1456,105 @@ struct MatchSubstring { template struct MatchSubstring { static Status Exec(KernelContext* ctx, const ExecSpan& batch, ExecResult* out) { - auto options = MatchSubstringState::Get(ctx); - if (options.ignore_case) { + return ExecWithState(ctx, batch, out, MatchSubstringState::GetState(ctx)); + } + + static Status ExecWithState(KernelContext* ctx, const ExecSpan& batch, ExecResult* out, + MatchSubstringState* state) { + if (state->options.ignore_case) { #ifdef ARROW_WITH_RE2 const bool is_utf8 = is_string_or_string_view(batch[0].type()->id()); - ARROW_ASSIGN_OR_RAISE(auto matcher, RegexSubstringMatcher::Make(options, is_utf8, - /*literal=*/true)); + ARROW_ASSIGN_OR_RAISE( + auto* matcher, state->GetOrInitMatcher( + state->regex_substring_matcher, + state->regex_substring_matcher_holder, [&]() { + return RegexSubstringMatcher::Make(state->options, is_utf8, + /*literal=*/true); + })); return MatchSubstringImpl::Exec(ctx, batch, out, - matcher.get()); + matcher); #else return Status::NotImplemented("ignore_case requires RE2"); #endif } - ARROW_ASSIGN_OR_RAISE(auto matcher, PlainSubstringMatcher::Make(options)); + ARROW_ASSIGN_OR_RAISE( + auto* matcher, + state->GetOrInitMatcher( + state->plain_substring_matcher, state->plain_substring_matcher_holder, + [&]() { return PlainSubstringMatcher::Make(state->options); })); return MatchSubstringImpl::Exec(ctx, batch, out, - matcher.get()); + matcher); } }; template struct MatchSubstring { static Status Exec(KernelContext* ctx, const ExecSpan& batch, ExecResult* out) { - auto options = MatchSubstringState::Get(ctx); - if (options.ignore_case) { + return ExecWithState(ctx, batch, out, MatchSubstringState::GetState(ctx)); + } + + static Status ExecWithState(KernelContext* ctx, const ExecSpan& batch, ExecResult* out, + MatchSubstringState* state) { + if (state->options.ignore_case) { #ifdef ARROW_WITH_RE2 - MatchSubstringOptions converted_options = options; - converted_options.pattern = "^" + RE2::QuoteMeta(options.pattern); const bool is_utf8 = is_string_or_string_view(batch[0].type()->id()); - ARROW_ASSIGN_OR_RAISE(auto matcher, - RegexSubstringMatcher::Make(converted_options, is_utf8)); + ARROW_ASSIGN_OR_RAISE( + auto* matcher, + state->GetOrInitMatcher( + state->regex_substring_matcher, state->regex_substring_matcher_holder, + [&]() { + MatchSubstringOptions converted_options = state->options; + converted_options.pattern = "^" + RE2::QuoteMeta(state->options.pattern); + return RegexSubstringMatcher::Make(converted_options, is_utf8); + })); return MatchSubstringImpl::Exec(ctx, batch, out, - matcher.get()); + matcher); #else return Status::NotImplemented("ignore_case requires RE2"); #endif } - ARROW_ASSIGN_OR_RAISE(auto matcher, PlainStartsWithMatcher::Make(options)); + ARROW_ASSIGN_OR_RAISE( + auto* matcher, + state->GetOrInitMatcher( + state->plain_starts_with_matcher, state->plain_starts_with_matcher_holder, + [&]() { return PlainStartsWithMatcher::Make(state->options); })); return MatchSubstringImpl::Exec(ctx, batch, out, - matcher.get()); + matcher); } }; template struct MatchSubstring { static Status Exec(KernelContext* ctx, const ExecSpan& batch, ExecResult* out) { - auto options = MatchSubstringState::Get(ctx); - if (options.ignore_case) { + return ExecWithState(ctx, batch, out, MatchSubstringState::GetState(ctx)); + } + + static Status ExecWithState(KernelContext* ctx, const ExecSpan& batch, ExecResult* out, + MatchSubstringState* state) { + if (state->options.ignore_case) { #ifdef ARROW_WITH_RE2 - MatchSubstringOptions converted_options = options; - converted_options.pattern = RE2::QuoteMeta(options.pattern) + "$"; const bool is_utf8 = is_string_or_string_view(batch[0].type()->id()); - ARROW_ASSIGN_OR_RAISE(auto matcher, - RegexSubstringMatcher::Make(converted_options, is_utf8)); + ARROW_ASSIGN_OR_RAISE( + auto* matcher, + state->GetOrInitMatcher( + state->regex_substring_matcher, state->regex_substring_matcher_holder, + [&]() { + MatchSubstringOptions converted_options = state->options; + converted_options.pattern = RE2::QuoteMeta(state->options.pattern) + "$"; + return RegexSubstringMatcher::Make(converted_options, is_utf8); + })); return MatchSubstringImpl::Exec(ctx, batch, out, - matcher.get()); + matcher); #else return Status::NotImplemented("ignore_case requires RE2"); #endif } - ARROW_ASSIGN_OR_RAISE(auto matcher, PlainEndsWithMatcher::Make(options)); - return MatchSubstringImpl::Exec(ctx, batch, out, - matcher.get()); + ARROW_ASSIGN_OR_RAISE( + auto* matcher, + state->GetOrInitMatcher( + state->plain_ends_with_matcher, state->plain_ends_with_matcher_holder, + [&]() { return PlainEndsWithMatcher::Make(state->options); })); + return MatchSubstringImpl::Exec(ctx, batch, out, matcher); } }; @@ -1536,54 +1646,73 @@ struct MatchLike { "should only codegen on physical types"); static Status Exec(KernelContext* ctx, const ExecSpan& batch, ExecResult* out) { - ARROW_ASSIGN_OR_RAISE( - const auto like_constants, - MatchLikeConstants::Instance( - /*is_utf8=*/is_string_or_string_view(batch[0].type()->id()))); - - auto original_options = MatchSubstringState::Get(ctx); - auto original_state = ctx->state(); - - Status status; - std::string pattern; - bool matched = false; - if (!original_options.ignore_case) { - if ((matched = RE2::FullMatch(original_options.pattern, - like_constants->like_pattern_is_substring_match, - &pattern))) { - MatchSubstringOptions converted_options{pattern, original_options.ignore_case}; - MatchSubstringState converted_state(converted_options); - ctx->SetState(&converted_state); - status = - MatchSubstring::Exec(ctx, batch, out); - } else if ((matched = RE2::FullMatch(original_options.pattern, - like_constants->like_pattern_is_starts_with, - &pattern))) { - MatchSubstringOptions converted_options{pattern, original_options.ignore_case}; - MatchSubstringState converted_state(converted_options); - ctx->SetState(&converted_state); - status = - MatchSubstring::Exec(ctx, batch, out); - } else if ((matched = RE2::FullMatch(original_options.pattern, - like_constants->like_pattern_is_ends_with, - &pattern))) { - MatchSubstringOptions converted_options{pattern, original_options.ignore_case}; - MatchSubstringState converted_state(converted_options); - ctx->SetState(&converted_state); - status = - MatchSubstring::Exec(ctx, batch, out); + auto* original_state = MatchSubstringState::GetState(ctx); + auto kind = original_state->like_kind.load(std::memory_order_acquire); + if (ARROW_PREDICT_FALSE(kind == MatchSubstringState::LikeKind::kNone)) { + std::lock_guard lock(original_state->mutex); + kind = original_state->like_kind.load(std::memory_order_relaxed); + if (kind == MatchSubstringState::LikeKind::kNone) { + ARROW_ASSIGN_OR_RAISE( + const auto like_constants, + MatchLikeConstants::Instance( + /*is_utf8=*/is_string_or_string_view(batch[0].type()->id()))); + + const auto& original_options = original_state->options; + std::string pattern; + bool matched = false; + MatchSubstringState::LikeKind determined_kind; + std::unique_ptr determined_state; + + if (!original_options.ignore_case) { + if ((matched = RE2::FullMatch(original_options.pattern, + like_constants->like_pattern_is_substring_match, + &pattern))) { + determined_kind = MatchSubstringState::LikeKind::kSubstring; + determined_state = std::make_unique( + MatchSubstringOptions{pattern, false}); + } else if ((matched = RE2::FullMatch( + original_options.pattern, + like_constants->like_pattern_is_starts_with, &pattern))) { + determined_kind = MatchSubstringState::LikeKind::kStartsWith; + determined_state = std::make_unique( + MatchSubstringOptions{pattern, false}); + } else if ((matched = RE2::FullMatch(original_options.pattern, + like_constants->like_pattern_is_ends_with, + &pattern))) { + determined_kind = MatchSubstringState::LikeKind::kEndsWith; + determined_state = std::make_unique( + MatchSubstringOptions{pattern, false}); + } + } + + if (!matched) { + determined_kind = MatchSubstringState::LikeKind::kRegex; + determined_state = std::make_unique(MatchSubstringOptions{ + MakeLikeRegex(original_options), original_options.ignore_case}); + } + + original_state->like_state = std::move(determined_state); + original_state->like_kind.store(determined_kind, std::memory_order_release); + kind = determined_kind; } } - if (!matched) { - MatchSubstringOptions converted_options{MakeLikeRegex(original_options), - original_options.ignore_case}; - MatchSubstringState converted_state(converted_options); - ctx->SetState(&converted_state); - status = MatchSubstring::Exec(ctx, batch, out); + switch (kind) { + case MatchSubstringState::LikeKind::kSubstring: + return MatchSubstring::ExecWithState( + ctx, batch, out, original_state->like_state.get()); + case MatchSubstringState::LikeKind::kStartsWith: + return MatchSubstring::ExecWithState( + ctx, batch, out, original_state->like_state.get()); + case MatchSubstringState::LikeKind::kEndsWith: + return MatchSubstring::ExecWithState( + ctx, batch, out, original_state->like_state.get()); + case MatchSubstringState::LikeKind::kRegex: + return MatchSubstring::ExecWithState( + ctx, batch, out, original_state->like_state.get()); + default: + return Status::Invalid("Invalid like pattern kind"); } - ctx->SetState(original_state); - return status; } }; diff --git a/cpp/src/arrow/compute/kernels/scalar_string_test.cc b/cpp/src/arrow/compute/kernels/scalar_string_test.cc index 74650c1d76c5..a6ab184548a3 100644 --- a/cpp/src/arrow/compute/kernels/scalar_string_test.cc +++ b/cpp/src/arrow/compute/kernels/scalar_string_test.cc @@ -15,8 +15,10 @@ // specific language governing permissions and limitations // under the License. +#include #include #include +#include #include #include @@ -25,6 +27,7 @@ #include "arrow/compute/api_scalar.h" #include "arrow/compute/exec.h" +#include "arrow/compute/expression.h" #include "arrow/compute/kernels/codegen_internal.h" #include "arrow/compute/kernels/test_util_internal.h" #include "arrow/testing/gtest_util.h" @@ -1703,6 +1706,41 @@ TYPED_TEST(TestBaseBinaryKernels, MatchSubstring) { &options_empty); } +TYPED_TEST(TestBaseBinaryKernels, MatchSubstringConcurrentFirstUse) { + auto in_schema = schema({field("x", this->type())}); + MatchSubstringOptions options{"ab"}; + auto expr = call("match_substring", {field_ref("x")}, options); + ASSERT_OK_AND_ASSIGN(auto bound_expr, expr.Bind(*in_schema)); + + Datum input = + ArrayFromJSON(this->type(), R"(["abc", "acb", "cab", null, "bac", "AB"])"); + ExecBatch batch({input}, 6); + + constexpr int kNumThreads = 16; + std::vector threads; + std::vector> results(kNumThreads); + std::atomic ready{false}; + + for (int i = 0; i < kNumThreads; ++i) { + threads.emplace_back([&, i]() { + while (!ready.load(std::memory_order_relaxed)) { + } + results[i] = ExecuteScalarExpression(bound_expr, batch); + }); + } + ready.store(true); + + for (auto& t : threads) { + t.join(); + } + + Datum expected = ArrayFromJSON(boolean(), "[true, false, true, null, false, false]"); + for (int i = 0; i < kNumThreads; ++i) { + ASSERT_OK(results[i].status()); + AssertDatumsEqual(expected, results[i].ValueOrDie()); + } +} + #ifdef ARROW_WITH_RE2 TYPED_TEST(TestBaseBinaryKernels, MatchSubstringIgnoreCase) { MatchSubstringOptions options_insensitive{"aƩ(", /*ignore_case=*/true}; @@ -1927,6 +1965,41 @@ TYPED_TEST(TestBaseBinaryKernels, MatchLikeEscaping) { this->CheckUnary("match_like", R"(["\n\tfoo\t", "\n\t", "\n"])", boolean(), "[true, true, false]", &escape_sequences); } + +TYPED_TEST(TestBaseBinaryKernels, MatchLikeConcurrentFirstUse) { + auto in_schema = schema({field("x", this->type())}); + MatchSubstringOptions options{"%ab%"}; + auto expr = call("match_like", {field_ref("x")}, options); + ASSERT_OK_AND_ASSIGN(auto bound_expr, expr.Bind(*in_schema)); + + Datum input = + ArrayFromJSON(this->type(), R"(["abc", "acb", "cab", null, "bac", "AB"])"); + ExecBatch batch({input}, 6); + + constexpr int kNumThreads = 16; + std::vector threads; + std::vector> results(kNumThreads); + std::atomic ready{false}; + + for (int i = 0; i < kNumThreads; ++i) { + threads.emplace_back([&, i]() { + while (!ready.load(std::memory_order_relaxed)) { + } + results[i] = ExecuteScalarExpression(bound_expr, batch); + }); + } + ready.store(true); + + for (auto& t : threads) { + t.join(); + } + + Datum expected = ArrayFromJSON(boolean(), "[true, false, true, null, false, false]"); + for (int i = 0; i < kNumThreads; ++i) { + ASSERT_OK(results[i].status()); + AssertDatumsEqual(expected, results[i].ValueOrDie()); + } +} #endif TYPED_TEST(TestBaseBinaryKernels, SplitBasics) { From e18a4e51b3a8a3ecfc8a11d383f8ed45dc3e9c04 Mon Sep 17 00:00:00 2001 From: Ishaan Maheshwari Date: Sat, 17 Oct 2026 00:57:45 +0530 Subject: [PATCH 2/2] GH-53159: [C++][Compute] Add multi-batch benchmarks for substring and regex matchers --- .../kernels/scalar_string_benchmark.cc | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/cpp/src/arrow/compute/kernels/scalar_string_benchmark.cc b/cpp/src/arrow/compute/kernels/scalar_string_benchmark.cc index a2cfbd059743..734a3b058602 100644 --- a/cpp/src/arrow/compute/kernels/scalar_string_benchmark.cc +++ b/cpp/src/arrow/compute/kernels/scalar_string_benchmark.cc @@ -116,6 +116,63 @@ static void MatchLikeSuffix(benchmark::State& state) { } #endif +static void MatcherReusedBenchmark(benchmark::State& state, const std::string& func_name, + const FunctionOptions* options) { + const int64_t total_length = 1 << 20; + const int64_t num_batches = state.range(0); + const int64_t batch_length = total_length / num_batches; + const int64_t value_min_size = 0; + const int64_t value_max_size = 32; + const double null_probability = 0.01; + random::RandomArrayGenerator rng(kSeed); + + std::vector batches; + batches.reserve(num_batches); + int64_t total_bytes = 0; + for (int64_t i = 0; i < num_batches; ++i) { + auto batch = + rng.String(batch_length, value_min_size, value_max_size, null_probability); + total_bytes += batch->data()->buffers[2]->size(); + batches.emplace_back(std::move(batch)); + } + + for (auto _ : state) { + auto func_exec = GetFunctionExecutor(func_name, {utf8()}, options).ValueOrDie(); + for (const auto& batch : batches) { + ABORT_NOT_OK(func_exec->Execute({batch})); + } + } + state.SetItemsProcessed(state.iterations() * total_length); + state.SetBytesProcessed(state.iterations() * total_bytes); +} + +static void MatchSubstringReused(benchmark::State& state) { + MatchSubstringOptions options("abac"); + MatcherReusedBenchmark(state, "match_substring", &options); +} + +#ifdef ARROW_WITH_RE2 +static void MatchSubstringRegexSimpleReused(benchmark::State& state) { + MatchSubstringOptions options("abac"); + MatcherReusedBenchmark(state, "match_substring_regex", &options); +} + +static void MatchSubstringRegexExpensiveReused(benchmark::State& state) { + MatchSubstringOptions options("([a-zA-Z0-9_.+-]+)@([a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+)"); + MatcherReusedBenchmark(state, "match_substring_regex", &options); +} + +static void MatchLikeFastPathReused(benchmark::State& state) { + MatchSubstringOptions options("%abac%"); + MatcherReusedBenchmark(state, "match_like", &options); +} + +static void MatchLikeRegexFallbackReused(benchmark::State& state) { + MatchSubstringOptions options("ab%ac"); + MatcherReusedBenchmark(state, "match_like", &options); +} +#endif + #ifdef ARROW_WITH_UTF8PROC static void Utf8Upper(benchmark::State& state) { UnaryStringBenchmark(state, "utf8_upper"); @@ -251,6 +308,13 @@ BENCHMARK(MatchLikeSubstring); BENCHMARK(MatchLikePrefix); BENCHMARK(MatchLikeSuffix); #endif +BENCHMARK(MatchSubstringReused)->RangeMultiplier(8)->Range(1, 64); +#ifdef ARROW_WITH_RE2 +BENCHMARK(MatchSubstringRegexSimpleReused)->RangeMultiplier(8)->Range(1, 64); +BENCHMARK(MatchSubstringRegexExpensiveReused)->RangeMultiplier(8)->Range(1, 64); +BENCHMARK(MatchLikeFastPathReused)->RangeMultiplier(8)->Range(1, 64); +BENCHMARK(MatchLikeRegexFallbackReused)->RangeMultiplier(8)->Range(1, 64); +#endif #ifdef ARROW_WITH_UTF8PROC BENCHMARK(Utf8Lower); BENCHMARK(Utf8Upper);