Skip to content

Commit 709e821

Browse files
authored
Add Glushkov regex fast-path to libcudf (#21936)
### Add bit-parallel Glushkov NFA regex engine with shared memory optimization Implement Glushkov's NFA for regex string matching in libcudf: references (1) [hyperscan paper](https://www.usenix.org/system/files/nsdi19-wang-xiang.pdf) (2) [HybridSA paper](https://dl.acm.org/doi/10.1145/3689771) (3) [vectorscan repo](https://github.com/Vectorcamp/vectorscan). Basically, this is Glushkov's NFA compared with the other popular Thompson's NFA (also used in current libcudf regex). The Glushkov engine represents NFA state as a single uint64_t bitmask (max 64 positions), requiring no global memory per thread. Shared memory is used to hold the static instructions like in the current implementation. ### Key changes - Two-phase O(n) unanchored search algorithm (glushkov.inl): Phase 1 scans forward, injecting start states each character and recording provisional match ends. Phase 2 rescans only the match region to find the true leftmost start. Each character is processed at most twice. - Leftmost-first correctness via priority-kill (glushkov.cuh, glushkov_regcomp.cpp): A runtime glushkov_priority_kill clears lower-priority alternative paths at accept time. A compile-time conflict detector (frontier_has_priority_conflict) conservatively falls back to Thompson when bit-index ordering cannot guarantee Thompson-compatible leftmost-first semantics. - Automatic fallback: Patterns with anchors (^, $, \b, \B), >64 positions, match empty top-level expressions, capture group requirements (extract, backref_re), or priority conflicts transparently fall back to Thompson NFA — no user intervention needed. ### Limitations - does not support capturing groups (e.g. extract, extract_all, findall, replace_with_backrefs) - does not support zero-width assertions (empty-matchable) like BOL/EOL/BOW/NBOW - max 64 character-consuming positions since we are using uint64_t as state data per row/thread - does not support lazy quantifiers - empty/degenerate patterns rejected - does not support empty-matchable patterns as well as some ambiguous alternation patterns When above condition is detected, it falls back to use the current Thompson's NFA. ### Unit tests + benchmark - Priority-kill parity tests: Verify Glushkov matches Thompson for overlapping-prefix alternations (foo|foobar, cat|catch, a|aa) across all 5 operations (contains, count, findall, replace, split) - Empty-matchable fallback parity: Confirm nullable patterns (a*, \d*, (ab)?) transparently fall back to Thompson and produce identical results - Spark-rapids compatibility: ~60 regex patterns from spark-rapids integration tests validated under both engines via parametrized Python tests - Benchmarks: 6–9 patterns per benchmark covering char classes, alternation, bounded repetition, dot wildcards, and late-failure stress patterns - Extended more complex regexes in the current split_re/contains/replace_re/count, it showed 1.01-6.62x speedup. Authors: - Lingyan Yin (https://github.com/lingyany-nv) - David Wendt (https://github.com/davidwendt) Approvers: - Basit Ayantunde (https://github.com/lamarrr) - Yunsong Wang (https://github.com/PointKernel) - Bradley Dice (https://github.com/bdice) URL: #21936
1 parent 0e94f6d commit 709e821

21 files changed

Lines changed: 1774 additions & 200 deletions

cpp/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,8 @@ add_library(
10451045
src/strings/merge/merge.cu
10461046
src/strings/padding.cu
10471047
src/strings/positions.cu
1048+
src/strings/regex/gkexec.cpp
1049+
src/strings/regex/glushkov_regcomp.cpp
10481050
src/strings/regex/regcomp.cpp
10491051
src/strings/regex/regexec.cpp
10501052
src/strings/regex/regex_program.cpp

cpp/src/strings/char_types/char_flags.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2019-2023, NVIDIA CORPORATION. All rights reserved.
2+
* SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
#pragma once
@@ -23,7 +23,8 @@
2323
// 0 - decimal
2424
//
2525

26-
uint8_t const g_character_codepoint_flags[] = {
26+
// clang-format off
27+
uint8_t const g_character_codepoint_flags[] = { // NOLINT
2728
0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 16, 16, 16, 0, 0, 0, 0, 0,
2829
0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 16, 16, 16, 0, 0, 0, 0, 0,
2930
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 7, 7, 7, 7, 7, 7, 7, 7,
@@ -3475,3 +3476,4 @@ uint8_t const g_character_codepoint_flags[] = {
34753476
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
34763477
0, 0, 0, 0, 0,
34773478
};
3479+
// clang-format on

cpp/src/strings/contains.cu

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ struct contains_fn {
3535
column_device_view const d_strings;
3636
bool const beginning_only;
3737

38-
__device__ bool operator()(size_type const idx,
39-
reprog_device const prog,
40-
int32_t const thread_idx)
38+
template <typename ProgDevice>
39+
__device__ bool operator()(size_type const idx, ProgDevice const prog, int32_t const thread_idx)
4140
{
4241
if (d_strings.is_null(idx)) return false;
4342
auto const d_str = d_strings.element<string_view>(idx);
4443

4544
size_type end = beginning_only ? 1 // match only the beginning of the string;
4645
: -1; // match anywhere in the string
47-
return prog.find<positional::END_ONLY>(thread_idx, d_str, d_str.begin(), end).has_value();
46+
return prog.template find<positional::END_ONLY>(thread_idx, d_str, d_str.begin(), end)
47+
.has_value();
4848
}
4949
};
5050

@@ -62,15 +62,18 @@ std::unique_ptr<column> contains_impl(strings_column_view const& input,
6262
mr);
6363
if (input.is_empty()) { return results; }
6464

65-
auto d_prog = regex_device_builder::create_prog_device(prog, stream);
66-
6765
auto d_results = results->mutable_view().data<bool>();
6866
auto const d_strings = column_device_view::create(input.parent(), stream);
6967

70-
launch_transform_kernel(
71-
contains_fn{*d_strings, beginning_only}, *d_prog, d_results, input.size(), stream);
72-
73-
results->set_null_count(input.null_count());
68+
if (regex_device_builder::glushkov_fast_path_supported(prog)) {
69+
auto d_prog = regex_device_builder::create_gkprog_device(prog, stream);
70+
launch_transform_kernel(
71+
contains_fn{*d_strings, beginning_only}, *d_prog, d_results, input.size(), stream);
72+
} else {
73+
auto d_prog = regex_device_builder::create_prog_device(prog, stream);
74+
launch_transform_kernel(
75+
contains_fn{*d_strings, beginning_only}, *d_prog, d_results, input.size(), stream);
76+
}
7477

7578
return results;
7679
}
@@ -133,11 +136,9 @@ std::unique_ptr<column> count_re(strings_column_view const& input,
133136
return count(input, target, stream, mr);
134137
}
135138

136-
auto d_prog = regex_device_builder::create_prog_device(prog, stream);
137-
138139
auto const d_strings = column_device_view::create(input.parent(), stream);
139140

140-
auto result = count_matches(*d_strings, *d_prog, stream, mr);
141+
auto result = count_matches(*d_strings, prog, stream, mr);
141142
if (input.has_nulls()) {
142143
result->set_null_mask(cudf::detail::copy_bitmask(input.parent(), stream, mr),
143144
input.null_count());

cpp/src/strings/count_matches.cu

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

66
#include "strings/count_matches.hpp"
7+
#include "strings/regex/regex_program_impl.h"
78
#include "strings/regex/utilities.cuh"
89

910
#include <cudf/column/column_device_view.cuh>
1011
#include <cudf/column/column_factories.hpp>
12+
#include <cudf/strings/regex/regex_program.hpp>
1113
#include <cudf/strings/string_view.cuh>
1214
#include <cudf/utilities/memory_resource.hpp>
1315

16+
#include <type_traits>
17+
1418
namespace cudf {
1519
namespace strings {
1620
namespace detail {
@@ -23,8 +27,9 @@ template <positional P>
2327
struct count_fn {
2428
column_device_view const d_strings;
2529

30+
template <typename ProgDevice>
2631
__device__ int32_t operator()(size_type const idx,
27-
reprog_device const prog,
32+
ProgDevice const prog,
2833
int32_t const thread_idx)
2934
{
3035
if (d_strings.is_null(idx)) return 0;
@@ -34,7 +39,7 @@ struct count_fn {
3439

3540
auto itr = d_str.begin();
3641
while (itr.position() <= nchars) {
37-
auto result = prog.find<P>(thread_idx, d_str, itr);
42+
auto result = prog.template find<P>(thread_idx, d_str, itr);
3843
if (!result) { break; }
3944
++count;
4045
// increment the iterator is faster than creating a new one
@@ -47,29 +52,64 @@ struct count_fn {
4752

4853
} // namespace
4954

55+
template <typename ProgDevice>
5056
std::unique_ptr<column> count_matches(column_device_view const& d_strings,
51-
reprog_device& d_prog,
57+
ProgDevice& d_prog,
58+
size_type strings_count,
5259
rmm::cuda_stream_view stream,
5360
rmm::device_async_resource_ref mr)
5461
{
5562
auto results = make_numeric_column(
56-
data_type{type_to_id<size_type>()}, d_strings.size(), mask_state::UNALLOCATED, stream, mr);
63+
data_type{type_to_id<size_type>()}, strings_count, mask_state::UNALLOCATED, stream, mr);
5764

58-
if (d_strings.size() == 0) { return results; }
65+
if (strings_count == 0) { return results; }
5966

6067
auto d_results = results->mutable_view().data<cudf::size_type>();
6168

62-
if (d_prog.is_empty_match_possible()) {
69+
// Glushkov's engine always requires the begin/end positional check; the Thompson
70+
// engine can skip it (cheaper) when an empty match is not possible for this pattern.
71+
if constexpr (std::is_same_v<ProgDevice, gkprog_device>) {
6372
launch_transform_kernel(
64-
count_fn<positional::BEGIN_END>{d_strings}, d_prog, d_results, d_strings.size(), stream);
73+
count_fn<positional::BEGIN_END>{d_strings}, d_prog, d_results, strings_count, stream);
6574
} else {
66-
launch_transform_kernel(
67-
count_fn<positional::END_ONLY>{d_strings}, d_prog, d_results, d_strings.size(), stream);
75+
if (d_prog.is_empty_match_possible()) {
76+
launch_transform_kernel(
77+
count_fn<positional::BEGIN_END>{d_strings}, d_prog, d_results, strings_count, stream);
78+
} else {
79+
launch_transform_kernel(
80+
count_fn<positional::END_ONLY>{d_strings}, d_prog, d_results, strings_count, stream);
81+
}
6882
}
6983

7084
return results;
7185
}
7286

87+
template std::unique_ptr<column> count_matches<reprog_device>(column_device_view const&,
88+
reprog_device&,
89+
size_type,
90+
rmm::cuda_stream_view,
91+
rmm::device_async_resource_ref);
92+
93+
template std::unique_ptr<column> count_matches<gkprog_device>(column_device_view const&,
94+
gkprog_device&,
95+
size_type,
96+
rmm::cuda_stream_view,
97+
rmm::device_async_resource_ref);
98+
99+
std::unique_ptr<column> count_matches(column_device_view const& d_strings,
100+
regex_program const& prog,
101+
rmm::cuda_stream_view stream,
102+
rmm::device_async_resource_ref mr)
103+
{
104+
auto const strings_count = d_strings.size();
105+
if (regex_device_builder::glushkov_fast_path_supported(prog)) {
106+
auto d_prog = regex_device_builder::create_gkprog_device(prog, stream);
107+
return count_matches(d_strings, *d_prog, strings_count, stream, mr);
108+
}
109+
auto d_prog = regex_device_builder::create_prog_device(prog, stream);
110+
return count_matches(d_strings, *d_prog, strings_count, stream, mr);
111+
}
112+
73113
} // namespace detail
74114
} // namespace strings
75115
} // namespace cudf

cpp/src/strings/count_matches.hpp

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2021-2024, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

@@ -15,23 +15,51 @@ namespace cudf {
1515
class column_device_view;
1616

1717
namespace strings {
18+
19+
class regex_program;
20+
1821
namespace detail {
1922

20-
class reprog_device;
23+
/**
24+
* @brief Returns a column of regex match counts for each string in the given column.
25+
*
26+
* A null entry will result in a zero count for that output row.
27+
*
28+
* This overload evaluates against an already-built device regex program. Callers that
29+
* also need the device program for other work (e.g. extraction) should build it once
30+
* and pass it here to avoid a redundant device program build.
31+
*
32+
* @tparam The regex prog device instance used for this API
33+
* @param d_strings Device view of the input strings column
34+
* @param d_prog Device regex program to evaluate on each string
35+
* @param strings_count Number of strings (and rows in the output column)
36+
* @param stream CUDA stream used for device memory operations and kernel launches
37+
* @param mr Device memory resource used to allocate the returned column's device memory
38+
* @return Integer column of match counts
39+
*/
40+
template <typename ProgDevice>
41+
std::unique_ptr<column> count_matches(column_device_view const& d_strings,
42+
ProgDevice& d_prog,
43+
size_type strings_count,
44+
rmm::cuda_stream_view stream,
45+
rmm::device_async_resource_ref mr);
2146

2247
/**
2348
* @brief Returns a column of regex match counts for each string in the given column.
2449
*
2550
* A null entry will result in a zero count for that output row.
2651
*
52+
* This overload builds its own device regex program. Prefer the overload above when
53+
* the device program is also needed for other work on the same call site.
54+
*
2755
* @param d_strings Device view of the input strings column.
28-
* @param d_prog Regex instance to evaluate on each string.
56+
* @param prog Regex program to evaluate on each string.
2957
* @param stream CUDA stream used for device memory operations and kernel launches.
3058
* @param mr Device memory resource used to allocate the returned column's device memory.
3159
* @return Integer column of match counts
3260
*/
3361
std::unique_ptr<column> count_matches(column_device_view const& d_strings,
34-
reprog_device& d_prog,
62+
regex_program const& prog,
3563
rmm::cuda_stream_view stream,
3664
rmm::device_async_resource_ref mr);
3765

cpp/src/strings/extract/extract_all.cu

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

@@ -106,9 +106,10 @@ std::unique_ptr<column> extract_all_record(strings_column_view const& input,
106106
auto const groups = d_prog->group_counts();
107107
CUDF_EXPECTS(groups > 0, "extract_all requires group indicators in the regex pattern.");
108108

109-
// Get the match counts for each string.
109+
// Get the match counts for each string, reusing the device program built above
110+
// instead of building a second one.
110111
// This column will become the output lists child offsets column.
111-
auto counts = count_matches(*d_strings, *d_prog, stream, mr);
112+
auto counts = count_matches(*d_strings, *d_prog, strings_count, stream, mr);
112113
auto d_counts = counts->mutable_view().data<size_type>();
113114

114115
// Compute null output rows

cpp/src/strings/regex/common.cuh

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
#pragma once
6+
7+
#include "regcomp.h"
8+
9+
#include <cudf/strings/detail/char_tables.hpp>
10+
#include <cudf/strings/detail/utf8.hpp>
11+
#include <cudf/types.hpp>
12+
13+
#include <cuda/std/optional>
14+
#include <cuda/std/utility>
15+
#include <thrust/execution_policy.h>
16+
#include <thrust/logical.h>
17+
18+
namespace cudf::strings::detail {
19+
20+
/// Bitmask type: bit i is set when Glushkov position i is active.
21+
using glushkov_state_t = uint64_t;
22+
23+
/// Maximum number of character-consuming positions (states) in the Glushkov NFA.
24+
/// Patterns with more positions fall back to Thompson NFA automatically.
25+
constexpr int32_t GLUSHKOV_MAX_STATES = sizeof(glushkov_state_t) * 8;
26+
27+
/// Maximum shift amounts for the Hyperscan-style shift-and optimization.
28+
constexpr int32_t GLUSHKOV_MAX_SHIFTS = 8;
29+
30+
/// Size of the precomputed ASCII reach table (characters 0–127).
31+
constexpr int32_t GLUSHKOV_ASCII_TABLE_SIZE = 128;
32+
33+
/**
34+
* @brief Regex class stored on the device and executed by reprog_device.
35+
*
36+
* This class holds the unique data for any regex CCLASS instruction.
37+
*/
38+
struct alignas(16) reclass_device {
39+
int32_t builtins{};
40+
int32_t count{};
41+
reclass_range const* literals{};
42+
43+
__device__ inline bool is_match(char32_t const ch, uint8_t const* codepoint_flags) const
44+
{
45+
if (thrust::any_of(thrust::seq, literals, literals + count, [ch](auto literal) {
46+
return ((ch >= literal.first) && (ch <= literal.last));
47+
})) {
48+
return true;
49+
}
50+
51+
if (!builtins) { return false; }
52+
auto const codept = utf8_to_codepoint(ch);
53+
constexpr uint32_t MAX_CODEPOINT = 0x00'FFFF;
54+
if (codept > MAX_CODEPOINT) { return false; }
55+
auto const fl = codepoint_flags[codept];
56+
if ((builtins & CCLASS_W) && ((ch == '_') || IS_ALPHANUM(fl))) { return true; } // \w
57+
if ((builtins & CCLASS_S) && IS_SPACE(fl)) { return true; } // \s
58+
if ((builtins & CCLASS_D) && IS_DIGIT(fl)) { return true; } // \d
59+
if ((builtins & NCCLASS_W) && ((ch != '\n') && (ch != '_') && !IS_ALPHANUM(fl))) { // \W
60+
return true;
61+
}
62+
if ((builtins & NCCLASS_S) && !IS_SPACE(fl)) { return true; } // \S
63+
if ((builtins & NCCLASS_D) && ((ch != '\n') && !IS_DIGIT(fl))) { return true; } // \D
64+
65+
return false;
66+
}
67+
};
68+
69+
/**
70+
* @brief Check for supported new-line characters
71+
*
72+
* '\n, \r, \u0085, \u2028, or \u2029'
73+
*/
74+
CUDF_HOST_DEVICE __forceinline__ constexpr bool is_newline(char32_t const ch)
75+
{
76+
return (ch == '\n' || ch == '\r' || ch == 0x00c285 || ch == 0x00e280a8 || ch == 0x00e280a9);
77+
}
78+
79+
/**
80+
* @brief Template type used on `find` to specify desired position values in returned match_result
81+
*/
82+
enum class positional : int8_t {
83+
BEGIN_END = 0, /// both begin and end positions are returned
84+
END_ONLY = 1, /// only the end position is returned
85+
};
86+
87+
using match_pair = cuda::std::pair<cudf::size_type, cudf::size_type>;
88+
using match_result = cuda::std::optional<match_pair>;
89+
90+
} // namespace cudf::strings::detail

0 commit comments

Comments
 (0)