Multiple regex performance shootout: RE2 vs. Intel's Hyperscan
01.org
01.org
Single-pattern performance has been covered before: e.g. https://rust-leipzig.github.io/regex/2017/03/28/comparison-o...
Also note that the rust regex crate now uses the same SIMD algorithm(Teddy) invented as part of Hyperscan, see: http://blog.burntsushi.net/ripgrep/ (also ripgrep is awesome, and you should use it.)
Given the comparison with RE2 here is under circumstances where RE2::Set is not returning any offset at all, I'm not sure what your point is about the "Intel piece".
Ripgrep is also awesome, and thanks (I guess) for mentioning Teddy, which nicely illustrates my favorite instruction (PSHUFB). There are still some bits of the Teddy algorithm that should be incorporated into ripgrep (the merging of literals when you have >8 literals is naive).
Think about quicksort. Quicksort is O(n^2) worst case, so it must be awful, right? Well, no, not really, because with a sensible choice of pivot you rarely hit that case, and the constant overhead is significantly better than the guaranteed O(n log n) mergesort for common input. Regex algorithms have a similar dynamic.
My understanding is that re2 and rust-regex manage parity with the backtracking algorithm for non-pathological regexes, much as the complicated mergesort-based Timsort can achieve parity with quicksort, but they have to do a lot of work to get there.
The exponential algorithms are commonly used because (a) they rarely hit their exponential behavior in practice, (b) they offer huge numbers of features that automata based algorithms cannot easily supply (capturing subexpressions and backreferences are the most obvious - yes, you can do capturing in automata but it's pretty hard) and (c) they are simpler than building something like Hyperscan.
Our codebase is enormous compared to a relatively feature-rich back-tracker. Partly this is because we've been at this a while and under constant pressure to do well on lots of cases and metrics (a cleaner, 'toy' system would look nicer but maybe we wouldn't have survived as a startup if we hadn't optimized 'that one case'). But if you want to build out a Glushkov NFA graph and do a ton of optimizations, you're already more complex than a backtracking engine and you're just getting started.
I'm not sure how that is saying anything substantatively different from what I said... Point (a) is what I was alluding to with the quicksort analogy: you rarely hit the O(n^2) behavior with quicksort. Point (b) is what I was talking about with backtracking. Point (c) is what my simplicity remark was describing.
I didn't mean to imply that you can't go as fast as a good backtracking JIT, rather that it's an awful lot of work to.
That said, I do think that building the NFA or converting to DFA takes up-front time, right? Some regex benchmarks create regexes dynamically at runtime over and over and this can add up.
Generally - if you can amortize the startup - we find that it's not that hard to outperform back-trackers. Some of this is chucking effort at the problem (writing SIMD implementations of some parts of the search problem) and could be replicated on the backtracking side of the fence. I do think it's simpler to make an NFA or DFA go fast, however, as the problem is very regular and predictable. The analysis of how you would do more than trivial SIMD optimizations on a true back-tracker break my brain. :-)
Huh, I thought it was fairly easy -- you just need a non-consuming state that pushes a mark onto a stack or pops one off of it. At least, I don't remember much of a problem implementing it when I implemented regex for Myrddin.
The trickiest part of getting matches to work right was getting the order of operations right so that when the VM forked, the state machine would pick the right order of operations to get greedy or non-greedy matches to end at the right place.
Backtracking is a far bigger problem, of course.
In our normal execution model, we also can't have a stack. Hyperscan is not allowed to go get more memory at run-time nor is it allowed to grab more storage proportional to the size of the input.
When we had a capturing prototype, we broke a lot of these rules (you needed either O(N) or O(lg(N)) side storage). We never had something that entirely fit our normal strictures and still did capturing; we couldn't do it in streaming mode either. We had something that could exactly simulate what you'd get with libpcre in terms of capturing and quantifiers as long as you could live with block mode and some limits on scan size dictated by our side storage use. The main problem? It was complex, and we didn't have a customer for it (this was back when we were a startup).
Sometimes users want these features more than they want theoretical purity. Who are we to tell them they are wrong?
That said, we do have some projects in mind to try to bridge these two worlds (feature-rich backtracking world vs speedy/streamable automata-based). Anyone interested should contact the Hyperscan team. Make a nice project at undergraduate or even postgraduate level depending on scale.
But... backreferences are impossible [0] to do efficiently [1] in any algorithm!
[0] Well, assuming P != NP. [1] Polynomial-time.
The fact that you have to grow the regex* to get this result has a similar vibe to it. All the regex matching we see - and most conventional regex matching - assumes the regular expressions are fixed. Every now any then you'll see an algorithm where the size of the regex contributes to the big-O notation but then it's usually broken out as a parameter.
I don't have a good method to actually do a fixed-number-of-backreferences in less than exponential time but it seems fairly clear that if you are willing to burn polynomial time gratuitously you should be able to handle a back-reference (i.e. for input of length N and M back-references, there are only O(N^(2M)) possible final places where those back-references could match for a single match... huge but if M fixed, not polynomial). So if you had an otherwise poly-time regex algorithm you could do it over and over again trying each possible back-ref location and still be poly-time.
I'm not sure I follow. Why would finding the max integer in a sorted list be anything other than the time to copy the last (or first, depending on sort order) element of the list?
Certainly a valid point, though, that this isn't a common use case, so that's just not how experts in this field think about this problem.
> there are only O(N^(2M)) possible final places where those back-references could match for a single match... huge but if M fixed, not polynomial).
Sure, that buys you a pseudo-polynomial algorithm. I just don't like the idea of calling something like that polynomial -- if we did that across the board, then we'd have a poly solution for knapsack (and consequently demonstrate P = NP).
The point is that number of back-references are not typically something that varies as part of a regex workload, so this proof derives something that is correct in itself, but not applicable to the way most people view regex. This has led to a meme that "all regex with backtracking is NP-hard".
My rather labored analogy is that people view comparison of two numbers as O(1) and would be nonplussed if someone demanded to know if they'd considered O(N) sized bignums... it isn't what most people are talking about when they talk about the cost of doing arithmetic operations. Similarly, arbitrarily expanding regexes is not what most people are talking about when they talk about the cost of doing regex.
The point of this engine was to show that, while it seems to be commonly believed that an automata-based regex engine cannot handle backreferences and arbitrary look around, that is not entirely true. It does use a stack when it encounters backreferences though.
The source code for the regex engine is at https://github.com/karthikj1/JRegexPlus and a brief explanation of how it works is at http://karthikj1.github.io/JRegexPlus/
I would be interested to hear any thoughts on this.
If those criteria frame why you are using an automata to begin with, then you either (a) can't use a stack either or (b) you at least need to make a performance argument that using NFA+stack is better than a back-tracker.
Arbitrary lookaround seems straightforward except in streaming mode, although efficient support for forward asserts for arbitrary regexes without "grows with the input size" side storage is IMO not trivial (it also breaks our ordering model). Backward lookaround seems easy if you are OK with adding logical AND to your NFA.
"Because it enables very useful features like backtracking" doesn't really sound beyond comprehension.
* Hyperscan is by far the fastest, but has only limited platform and regex support.
* Next is pcre2 (with the jit), which is a bit faster than the new rust regex. These support all options, everywhere.
* Then RE2 and all the others. RE2 has very limited regex support.
Why they compare the fastest with one of the slower and limited ones is beyond my understanding. The best overview is still https://rust-leipzig.github.io/regex/2017/03/28/comparison-o...
RE2 is fine, but nobody uses it because... What do you think why? Because of no backtracking support. If I need a limited fast regex on modern intel-CPU's with SIMD, I'll choose Hyperscan over everything else. But I need to check the cpu capabilities before loading the shared lib. If I need a fast regex everywhere else, I choose PCRE2 over everything else. RE2 had its time for the last decade, but this time is over now.
Firstly, that link shows PCRE2 and Rust's regex engine are neck-and-neck. And that's including the fact that the benchmark contains a regex that is hard for DFA engines but contains no regexes that are hard for backtrackers. (I lodged this criticism against the author of the benchmark before they published it.)
Secondly, Rust's regex crate is heavily inspired by RE2. They support roughly the same features. So if RE2 is limited, then so is Rust's regex library.
Rust's regex library tends to do a little better than RE2 in a wide variety of common use cases because of aggressive literal optimizations. But if you mask those out, performance will be comparable. (Also, if you benchmarked captures, I bet RE2 would win handedly. Rust's regex library needs some work in that area.)
I haven't checked Rust's feature list, sorry. I ruled it out for serious usage very early, and it's still evolving.
Captures are very important for us. Same as UTF8. There was once the idea to switch to a limited engine like RE2 after the first scan of the regex when it doesn't do backtracking. But then I would switch to Hyperscan instead, which has the best compiler, besides having a very good SIMD optimized runtime.
Hyperscan doesn't have a JIT. PCRE2 and sregex are not the only ones with a JIT. For example, PyPy's regex engine is JITed and so is v8's. The existence of Hyperscan (and Rust's regex engine) should show that a JIT is not necessary for high performance.
(Disclaimer: I am the author of Rust's regex library.)
Yes, pypy and v8 also have a jit, but not usable as library. ruby extracted their regex library, so I could use it also. pypy and v8 did not.
Similarly, if you have short-lived regexes that are compiled, used for a small amount of data, and discarded, you might never see a performance benefit.
Multiple regexes, scanning a substantial amount of data and/or having a requirement to 'stream' (i.e. process successive writes of input data when you can't hold on to old data) are the sort of things that make Hyperscan use more sensible. We see a lot of use in network security where these assumptions all generally hold.
or they have additional sanitisation or security features?
I'm not aware of any difference in terms of sanitisation or security.
To be honest, I have no idea if the "what the regex must do" description rules out particular algorithms. I suspect that, in keeping with Committee tradition, it's basically a new interface over POSIX ( http://pubs.opengroup.org/onlinepubs/7908799/xbd/re.html ). By "Committee tradition," I mean "compare 'catgets' with C++ message catalogs."
I wonder if I should switch gears and try to write a native Node module for this instead. This looks really great.
If you can share your workload, or want assistance in figuring out whether to do this and/or how to do it, please contact us on the mailing list or on the email link for Hyperscan on 01.org. We're always interested in sample patterns, especially ones that fall outside our normal wheelhouse of 'network security and then some more network security'.
This worked for me https://github.com/time4tea/glossary/blob/master/README.md
Its a very simple Aho-Corasick text search with replacement.
A similar thing in Java using Ropes rather than Strings was also ok, for some value of ok.
I've found the hardest part of using e.g. a recursive descent parser is having to correct captured state while backtracking.
Would you embed Brainfuck in your code ? Then why do you use regexes ?
That being said I'd be surprised to your open-coded version of simple regexes like /hatstand.*teakettle/ or /badgerbrush\s{12}/ was clearer than the regex, and it would almost certainly be dismally slow relative to a tuned regex implementation - unless you wrote a lot of one-off SIMD code.
Further, if given a bunch of different regexes to match simultaneously, your code would have to run them one by one.
Finally, quite frequently our customers/users are in a situation where they don't know what the regex is going to be, and they aren't going to let people inject arbitrary code into their applications.
So if anything I blame lack of good taste for regular expressions bad rep.
For example, in Python, if I want to match "foo" followed by any number of "bar"s and/or "quux"s at the end of the string, I could write this:
if re.match(r"foo(bar|quux)*$", my_string):
...
...without going to the trouble writing a recursive-descent parser or state machine, your way would be something like this: def ends_with_foo_etc(s):
if s.endswith("foo"):
return True
after_last = len(s) - s[::-1].find("oof")
if after_last > len(s):
return False
good_tail = True
while good_tail and after_last < len(s):
if s[after_last:after_last + 3] == "bar":
after_last += 3
elif s[after_last:after_last + 4] == "quux":
after_last += 4
else:
good_tail = False
return good_tail
if ends_with_foo_etc(my_string):
...
That's both excessively terse -- for a really simple pattern -- almost certainly slower and, most importantly, brittle in that it couples your pattern to your implementation in a non-trivial way. If your pattern changes, you may have to completely rewrite your matching function.It's also worth noting that a typical regex API will do more than just match patterns; e.g., extracting subpatterns/groups, splitting, etc.
Since this is anchored to the right, we can look at this from right to left: it's any mixture, including empty, of "quux" or "bar" elements, preceded by "foo". From this, a very simple algorithm pops out:
while (string does not end with "foo") {
if (string ends with "quux")
chop off "quux" from end of string
else if (string ends with "bar")
chop off "bar" from end of string
else
return "no match"
}
return "match"Writing fewer lines of code to do the same job is usually a good idea, providing it remains understandable. Personally, I find the syntax of regular expressions pretty straightforward and a lot of languages even allow you write comments within them to make them even easier to follow.
To be sure to truly understand your regex I would need to do a lot of Googling and a lot of thinking.
Regexes are not KISS IMO.