Skip to content

Fixups to AHO-CORASICK matching. - #24937

Open
demerphq wants to merge 20 commits into
bleadfrom
yves/octet_trie_followups
Open

demerphq wants to merge 20 commits into
bleadfrom
yves/octet_trie_followups

Conversation

@demerphq

Copy link
Copy Markdown
Collaborator

Resolve #24934 and
#24935.

Implements a grab bag of optimizations to the AHO-CORASICK start class optimization.

Uses Boyer-Moore-Horspool matching to construct a shift table which can allow scanning for the start of a match at very high speeds. (Think Boyer-Moore for a list of words.) However, BMH can slow things down in certain pathological cases, which is where we use a start class filter (similar to the old bitmap) to filter out bad starts, this combines with some logic to detect repeated short strings and consume them very quickly. The end result is that for the cases where the old bitmap was very fast we aren't quite as fast, but for the places where the bitmap was no help at all we can sometimes scream. Overall we seem to be very consistently fast, and sometimes very fast.

At the same time I added support for a start-octet map which uses the fold-inversion logic to build a bitmap we can use with utf8 folded data, which means case insensitive matches are now faster as well. Not as fast as case sensitive ones, alas.

There were also some other bookkeeping refinements which improved trie/ac matching, so even the base case got faster as well.

Lastly I found a bug that was introduced by the more aggressive prefix extraction that the octet patch included. An edge case that we hadnt encountered before, it is now fixed.

Median elapsed seconds; lower is faster. All builds use matching GCC 13.3 -O2 settings, without debugging or threads.

Workload Perl 5.44 blead/ Previous patch Latest patch
/walrus|cat/, 200M As + cat, Latin-1 0.3312 1.8074 0.4887 0.0478
Same, UTF-8 flagged 1.4617 1.8524 0.5023 0.0485
575 SV identifiers, source corpus, Latin-1 0.2730 0.4970 0.3206 0.2988
Same source, UTF-8 flagged 0.4455 0.5267 0.3097 0.3037
/walrus|cat/, random ASCII, Latin-1 0.0176 0.1004 0.0266 0.0299
/abcdefgh|ijklmnop/, random ASCII, Latin-1 0.0178 0.1042 0.0101 0.0302
Same, UTF-8 flagged 0.0807 0.1028 0.0106 0.0327
/fooA|barB/, repeated AB, Latin-1 0.0683 0.4054 0.00572 0.00621

Medians of three trials. The previous-patch column comes from the preceding benchmark run; the other three columns are fresh measurements.

Workload sizes:

  • 200M A case: three scans.
  • Source corpus: 2,084,667 octets, scanned 50 times.
  • Random ASCII: 1 MiB, scanned 32 times.
  • Repeated AB: 1 MiB, scanned 128 times.
  • This set of changes requires a perldelta entry, and it is included.

@demerphq

Copy link
Copy Markdown
Collaborator Author

@khwilliamson can you check out the bitmap for folded data please. I am not sure i used the inverted fold data properly.

@demerphq
demerphq force-pushed the yves/octet_trie_followups branch 3 times, most recently from 8319656 to 5c5f7ed Compare October 14, 2026 10:56
@richardleach

Copy link
Copy Markdown
Contributor

At first glance, on my dev VM.

  • my $x = ("A"x200_000_000)."cat"; $x =~ /walrus|cat/;': has gone from 5-6 times slower than pre-24712 to 2-3 times faster.
  • my $x = ("cat"x20_000_000)."cat"; while ($x =~ /walrus|cat/g) {} seems about 10% slower than on 5.44.0 though.

I'll start going through the commits in a bit.

@richardleach

Copy link
Copy Markdown
Contributor
  • my $x = ("cat"x20_000_000)."cat"; while ($x =~ /walrus|cat/g) {} seems about 10% slower than on 5.44.0 though.

Scrap that, I mustn't have had the latest updates from this branch.

Comment thread regexec.c Outdated
@demerphq

Copy link
Copy Markdown
Collaborator Author

my $x = ("cat"x20_000_000)."cat"; while ($x =~ /walrus|cat/g) {}

FWIW, this is the kind of benchmark I personally try to avoid. It doesn't do any work, so the timing is very artificial. Often when you put some kind of sane workload in the loop all of a sudden the 10% increase is a negligible increase because the work in the body swamps everything else.

@richardleach

Copy link
Copy Markdown
Contributor

S_regmatch comments at

perl5/regexec.c

Line 7318 in e910fa1

/* Start-class filtering is disabled while trie prefix

say that:

                /* Start-class filtering is disabled while trie prefix
                 * extraction is being brought into alignment with the
                 * executable start state. */

Could some of the same mechanisms introduced in this PR apply there too?

Comment thread regexec.c Outdated
Comment thread regexec.c
@demerphq
demerphq force-pushed the yves/octet_trie_followups branch from e910fa1 to ed58fb5 Compare October 16, 2026 22:32
@richardleach

Copy link
Copy Markdown
Contributor

Not sure if I'm understanding the implementation correctly, but I've got two main concerns that I'm still feeling out:

  1. If the space to search contains repetition with a periodicity that a word doesn't capture - such as "ABCABC..." or "ABCDEABCDE...", then it seems like the search will essentially fall back to octet-at-a-time. In particular, this seems like users operating in a three-octets-per-char alphabet get a raw deal.
  2. For users operating in a multi-octet alphabet, the start-octet of the trie might well match the start of every single character in that alphabet. For UTF8 patterns, would it be better the "start"-search to look for either (i) what looks like the final octet of the start-character, or (ii) the final octet that currently the BMH check looks for. Especially for longer automatons, should the UTF-8 case perform 2-3 more BHM probes to be more confident that the first/last characters really are a match?

@richardleach

Copy link
Copy Markdown
Contributor

Oh, I see that the interplay between BMH and start-octet search isn't quite what I'd originally pictured for UTF-8, so it's not as bad as all that.

@demerphq
demerphq force-pushed the yves/octet_trie_followups branch from 99d176c to 3e6820c Compare October 18, 2026 17:41
@demerphq

demerphq commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

@richardleach, following up on your last comment.

So, a bit of context for those not familiar with it. Boyer–Moore–Horspool matching is a variant of Boyer–Moore matching. The usual description searches for one string. We use a version adapted to finding positions where one of a set of strings might match.

It is easiest to picture with words of the same length. Suppose we have a bunch of six-octet words. BMH looks at the octet where a word would end, and uses a table to decide how far to advance. An entry of zero says “a word might end here, check further”. An entry of one says “advance one octet and check again”. An octet which appears nowhere in our words can have an entry of six, allowing us to skip six octets.

With varying word lengths, we use a common prefix length determined by the shortest word, subject to the table’s size limit. So the anchor is not necessarily the last octet of a longer word. We build separate BMH tables for native and UTF-8 targets.

We combine this with a check of the possible first octet. If the end-position check looks promising but the first octet cannot start any word, we can reject that position without entering Aho–Corasick proper.

The repeat scanner handles another part of this process. After checking a machine-word-sized window and finding no possible starts, we know that identical following windows cannot contain a start either. We can then chew through repeated data a machine word at a time. This is useful for long runs of spaces, where repeatedly consulting the BMH table can cost more than comparing and skipping a whole word.

Your point about odd-sized repeats led to another improvement. The scanner now handles periods of 3, 5, 6 and 7 octets as well. The trick is to compare enough machine words that the repeating sequence returns to the same position at each block boundary. On a 64-bit build, a three-octet repeat lines up again after three machine words, or 24 octets. This is particularly useful for Unicode whitespace, since many Unicode spaces occupy three UTF-8 octets.

These shortcuts apply when AC is at its root, with no partial word in progress. Once matching has started, AC transitions and failure links keep track of the possible words. When it returns to the root, the scanning shortcuts become available again.

I also tested restricting repeat detection according to the BMH shift, and adding a cheap probe before doing more repeat checks. Both helped some inputs and hurt others, so neither was retained as a general rule. The right choice depends on both the search words and the input. I do not think we have a useful universal cutoff yet.

Anyway, here are some numbers from the latest version. Times are arithmetic means in microseconds per benchmark iteration, from twelve interleaved samples with matching compiler settings. Negative percentages mean less time. These compare the complete builds, rather than isolating any one optimisation.

Workload 5.44 µs blead µs Latest µs Change vs 5.44 Change vs blead
Native ASCII, period 1 26.25 139.45 3.51 −86.6% −97.5%
Native ASCII, period 2 21.18 118.12 2.83 −86.6% −97.6%
Native ASCII, period 3 20.91 114.83 3.81 −81.8% −96.7%
Native ASCII, period 4 20.91 112.70 2.87 −86.3% −97.5%
Native ASCII, period 5 21.11 117.03 3.00 −85.8% −97.4%
Native ASCII, period 6 21.06 117.89 3.88 −81.6% −96.7%
Native ASCII, period 7 22.02 121.35 3.07 −86.0% −97.5%
Native ASCII, period 8 21.25 115.82 2.76 −87.0% −97.6%
UTF-8, different first octet 53.75 198.01 24.27 −54.8% −87.7%
UTF-8, shared first octet 154.39 150.13 32.47 −79.0% −78.4%
UTF-8, shared first and last octets 160.67 150.36 130.13 −19.0% −13.5%
Unicode whitespace, period 3 27.19 98.47 2.61 −90.4% −97.3%
Unicode whitespace, period 5 32.10 100.84 2.16 −93.3% −97.9%
Unicode whitespace, period 6 26.52 101.99 2.59 −90.2% −97.5%
Unicode whitespace, period 7 34.12 99.78 1.95 −94.3% −98.0%
HTML, absent attribute names 115.93 230.87 63.81 −45.0% −72.4%
Variable search in Perl source 185.94 280.20 220.86 +18.8% −21.2%
Variable search in heavily indented Perl 38.33 117.84 51.39 +34.1% −56.4%
Anchored native Latin-1, long words 1.15 1.52 1.41 +22.7% −7.0%
Anchored UTF-8 Latin-1, long words 1.32 1.69 1.55 +17.2% −8.5%
Dense matches: 4,096 copies of cat 5,427.46 5,717.69 6,124.62 +12.8% +7.1%

The repeated-input cases have about 32 KiB before a final match. The whitespace cases use actual Unicode spaces. The document cases scan 32,768 characters. Each anchored iteration tries three strings: two successful matches and one failure. The dense case returns 4,096 matches and includes counting them and accumulating their lengths. Input preparation and initial pattern construction are outside the timer.

So, for example, scanning three-octet Unicode whitespace fell from 98.47 µs on blead to 2.61 µs here.

There are also metrics which have degraded since 5.44. The source-search and anchored Latin-1 cases above all improve with this patch sequence relative to blead, although they have not recovered all the lost ground. I have plans to address the remaining representation costs in a follow-up. Dense matching is a separate remaining case where the new code is still slower.

I have worked through the implementation feedback too: removed redundant bounds checks, reduced position tracking, and avoided conversions where the input is already in the representation the trie needs. Some apparently obvious changes consistently lost in measurement, so I left them out. Code layout also proved sensitive: changing how the loop was expressed changed the generated branches enough to matter.

Construction costs are separate. Building the folded start maps adds a few microseconds to the small compilation cases we measured. That is paid when constructing the regex, while the scanning improvements apply each time it runs.

I think we are now in good territory. Next PR I want to try a hypothetical TRIE V2, with completely separate native and UTF-8 tables and structs, so that we can remove more of the representation-handling work from execution. Tries starting using codepoints, switched to utf8, and now I think they need to do latin-1 codepoints and utf8 octets. Sigh. Suggestions for further improvements are very welcome.

Comment thread regcomp_trie.c Outdated
Comment thread t/re/pat.t Outdated
Comment thread regcomp_trie.c Outdated
Comment thread t/re/pat.t Outdated
Comment thread t/re/pat.t Outdated
Comment thread regcomp_trie.c Outdated
Comment thread regcomp_trie.c Outdated
Comment thread regcomp_trie.c Outdated
@demerphq
demerphq force-pushed the yves/octet_trie_followups branch 2 times, most recently from 6d8e90b to 9b5d2c6 Compare October 20, 2026 14:03
@demerphq

Copy link
Copy Markdown
Collaborator Author

Following up on your review: I renamed the to_uni_upper parameter from ncp to uv in the declaration and implementation, and added an explanation of native-Unicode numbering near the start of the generated perlapi documentation. The API introduction now explains the 0..255/native and above-255/Unicode split, when NATIVE_TO_UNI and UNI_TO_NATIVE are useful, and that older names containing uni may use native-Unicode values.

@demerphq
demerphq force-pushed the yves/octet_trie_followups branch from 509a0c0 to 8b13e73 Compare October 27, 2026 12:40
@richardleach

Copy link
Copy Markdown
Contributor

Hey @khwilliamson and @richardleach you have any objections on this? IMO it moves the needle in the right direction and I have further steps predicated on merging it.

I've not looked closely in more than a week. Will look this afternoon, but probably not going to be anything from me.

(In terms of juicing finding the start position: where 1-4 masks is enough to describe the possible start octet, a S_find_next_masked_n applying to 64 octets-at-a-time seems to have the potentially to be very fast, but I've not fully worked through it and wouldn't know how to wire it in / with what fallback. I can play with it some more / tidy ready to share if it sounds useful. Don't know what your follow-on plans are.)

Comment thread regcomp_trie.c Outdated
Comment thread regexec.c Outdated
Comment thread regexec.c Outdated
Comment thread regexec.c Outdated
Comment thread regexec.c Outdated
Comment thread regexec.c
Comment thread regcomp_trie.c Outdated
We can skip much of the input before trying the Aho-Corasick matcher.
Build separate Boyer-Moore-Horspool shift tables for native and UTF-8
targets, and a bitmap of the octets which can start a word. The shift
tables let us skip ahead, and the bitmap rejects impossible starts.

When a whole UV contains no possible start, we can skip identical UVs as
well. This handles long runs of repeated characters without checking
each octet. We only do this at the root, where no word is in progress.

Avoid updating the position ring on failed root transitions, and reject
native targets outright when every word needs a codepoint above 255.

Partial fix for #24934
For folded words we need a map of the original target characters, not
their folded bytes. Use inverse folds to find those characters,
including characters whose multi-character folds can start a word. The
map may admit extra candidates, as the matcher checks the actual folding
rules.

Leave both BMH tables empty for folded tries. Offsets in a folded word
do not tell us how far we can skip in the original input. We can use the
start map and repeat scanner at the root when no encoded or folded input
is pending.

Resolves #24935
A nonempty folded literal needs an input character. HOP3c can leave the
last possible start at the end of a short target, where UTF8_SAFE_SKIP()
returns zero. We then retry the same failed match forever.

Check that input remains before trying the literal. This can happen on
the next /g attempt when prefix extraction leaves a folded start class.
The same problem occurs on blead without the AC prefilter changes.

Fixes #24934
Once we have checked a whole UV of octets which cannot start a match, an
identical UV cannot contain a start either. The BMH shift does not
affect that, so we need not restrict the repeat scanner to shifts of
zero or one.

For /walrus|cat/, A has a shift of three. We used to keep looking up
that shift on a long run of A's instead of scanning in UV sized chunks.

On Linux x86_64, nonthreaded GCC 13.3 -O2 builds, three scans of 200
million A's followed by cat drop from 0.489s to 0.048s. There is a cost
when input does not repeat: 32 scans of 1 MiB of random uppercase ASCII
for /abcdefgh|ijklmnop/ rise from 0.0101s to 0.0302s. Establishing the
rejected window checks octets which BMH could otherwise skip.
We check both the last possible start and the end of the string for
every candidate. Calculate one exclusive limit before the loop instead.

Cap it at strend. HOP3c can leave the last possible start at the end of
a short target, but a nonempty literal cannot match there. Trying it
would leave UTF8_SAFE_SKIP() returning zero without advancing the scan.
For nonfolded native matches, each input byte is one character. We can
find the start by subtracting the word length from the current pointer,
without recording every input position in a ring.

Keep the ring for UTF-8 and folded matches, but wrap its index with a
comparison instead of a remainder operation. Calculate where the initial
scan reaches a UV boundary once, rather than checking alignment for
every rejected octet.

The AC construction queue holds each state once, so its indexes need not
wrap either.
Use a line length of 100, with a few hand edits for clarity.
We need comparisons which distinguish ASCII, high native bytes and
upgraded input, using the same words and text for each Perl build.

Add t/re/trie_bench_corpus.pl to provide fixed HTML, prose, Perl-source
and FASTA workloads. Add t/re/trie_bench_corpus.t to check that we can
recreate the inputs and get the expected matches.

Add Porting/bench-trie.pl to run alternating samples across Perl builds
and report each workload separately. It checks match spans and AC start
classes before timing and records the inputs, build settings and raw
samples so we can repeat the comparison and investigate regressions.
Appending to the same list causes needless conflicts. Suggest choosing a
section at random when adding cases.
We already have the folder's UTF-8 bytes, so there is no need to decode
each character and encode it again. Feed those bytes to the trie
directly. Track pending octets separately from pending folded characters
so multi-character folds keep the right input positions.

For nonfolded UTF-8 AC matches, subtract the word's byte length to find
its start. This avoids the position ring and its heap allocation for
long words. Folded input still needs the ring, as its length can vary.

Once the acceptance chains are built, reuse each word's accept-state
slot for its byte length. We no longer need the accept state then, so
the word records need not grow.
GH #8267 and GH #16250 have passed since 77af3ea cleared captures
when CURLYX chooses zero repetitions. Remove the stale TODO cases.

GH #8267 already has ordinary regression coverage. Move GH #16250 to
re_tests, keeping the original inputs and covering both assertion forms,
with and without the outer lookahead.
A UTF-8-flagged ssx|sy pattern must match sharp-s followed by x under
/iu. Extracting the first s as a prefix splits the fold, so neither node
can match the input character. This regressed in 9c721a4.

Record the boundaries inside multi-character folds while building the
words. Only extract a prefix at a boundary which is safe for every
alternative, including overlapping folds in sss.
A folded word can lower the minimum length and raise the maximum at the
same time. The else-if chain only updated the minimum in that case.
Update both bounds independently.

For eeeeee|a\xB5\xDFSss under /iu, the old maximum leaves the AC
position ring too small for the second word. We can miss the match or
fail an assertion.
Under /iu, s|zoo must not match sharp-s by consuming only the first s of
its fold. When finding the end of a trie word, reject it if part of the
input character's fold is left over.

Try the next word in alternative order before running a continuation. A
longer word may consume the whole fold and match.
Every native byte needs at most two UTF octets. Store the first in the
low byte of a U16 and the second in the high byte. A zero high byte
means there is no second octet, as continuation octets cannot be zero.
This works for UTF-EBCDIC as well as UTF-8.

We can fetch the encoding with one load, and the table shrinks from 768
to 512 bytes. We use the platform encoder to build it, without assuming
ASCII byte values.
For nonfolded native input, consume both encoded octets in the same
iteration. We need not return to the general reader between them. Keep
both ownership checks, but only check for accepting words after the
whole character. A word cannot end inside its encoding.

Each native character is one input byte, so derive its position from the
input pointer instead of counting characters in the loop. Tracing uses
this same loop and shows the intermediate state on the character's
diagnostic line.

Together with packing, this saves about 8-13% of cycles on synthetic
forty-character high-byte native match and miss cases, compared with the
previous reader. These are nonthreaded GCC -O2 builds on an i7-1185G7.
Some short folded and wide UTF-8 controls cost 1-2% more.
Advance and check the bounds after each successful word comparison.
This gives GCC a smaller loop with shorter branches when scanning long
runs of identical words.
Compare several machine words at a time so each block ends at the same
position in the repeating sequence. This lets us skip runs such as
three-octet Unicode spaces that a single-word comparison cannot handle.

Try these periods when the ordinary word comparison fails. Check the run
before building its comparison block, and keep the larger fallback out
of the ordinary loop. On 32-bit builds, check the seed octets beyond the
first word before skipping any possible starts.
Until today I didn't properly appreciate that internally our codepoints
are not true Unicode codepoints, but actually hybrid codepoints where
0-255 map to the native encoding, and codepoints 256 and above follow
Unicode.  In ASCII environments the native codepoints and Unicode
codepoints are identical, but in EBCDIC they aren't.

So, "A" does not have codepoint 65 on EBCDIC. However this doesn't
matter because most of the functions you might use on "A" which cares
what codepoint is has will do the right thing in EBCDIC with the value
it does have.

Anyway, this patch updates some of the docs to use the term
native-Unicode for the unicode apis - and provides a definition of the
term so someone just reading a macro definition doesn't get confused.
It also explains the subtlties here a bit more clearly. I kind of think
that a function which takes a native-Unicode codepoint should call it
ncp not cp as well.

It does not change any code, just renames a var and update the comments
to help others avoid the confustion I experienced here.
@demerphq
demerphq force-pushed the yves/octet_trie_followups branch from 6ab6a01 to e16d969 Compare October 29, 2026 19:20
@demerphq

Copy link
Copy Markdown
Collaborator Author

Id like to get this applied, it has multiple good things in it, and I think does no new harm. Also I have a bunch of work predicated on it.

Skip the multi-character-fold closure scan when no folded trie start can
begin such a fold. Generate a classifier from the first code point of
each multi-character Case_Folding mapping, and track whether any trie
start needs the scan. Keep the existing closure calculation when one
does.

The classifier consumes an already folded UTF string. It identifies the
first character of a multi-character fold, rather than a character whose
own fold expands to multiple characters.

In a local comparison on CPU 2 using GCC 13.3, -O2, no debugging and no
threads, the four-alternative fixture without a relevant fold start fell
from a median 10.821 to 7.354 microseconds per compilation. The run used
24 alternating-order sample pairs targeting 0.4 seconds per baseline
sample. These synthetic measurements include compilation and
destruction, with setup excluded.
@demerphq
demerphq force-pushed the yves/octet_trie_followups branch from e16d969 to dc67cf0 Compare October 29, 2026 20:42
@richardleach

Copy link
Copy Markdown
Contributor

Id like to get this applied, it has multiple good things in it, and I think does no new harm. Also I have a bunch of work predicated on it.

I've got no new comments at present and don't want to hold you up at this stage in the dev cycle.

Only lingering concern is: how likely do you expect repeated UV blocks to feature in real-world workloads? Is it going to be enough for that part of the scanner to kick-in?

@demerphq

Copy link
Copy Markdown
Collaborator Author

Only lingering concern is: how likely do you expect repeated UV blocks to feature in real-world workloads? Is it going to be enough for that part of the scanner to kick-in?

Anecdotally I think scanning documents with lots of whitespace is pretty common. But its a good question.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Octet-trie fallout: Aho-Corasick scanning slowdown and folded /g hang

3 participants