Quickly checking that a string belongs to a small set
lemire.me
lemire.me
https://www.gnu.org/software/gperf/
For C++ users, frozen is also a possibility: https://github.com/serge-sans-paille/frozen
https://github.com/rurban/nbperf https://github.com/rurban/gperf
A static search structure is an Abstract Data Type with certain fundamental operations, e.g., initialize, insert, and retrieve. Conceptually, all insertions occur before any retrievals. It is a useful data structure for representing static search sets. Static search sets occur frequently in software system applications. Typical static search sets include compiler reserved words, assembler instruction opcodes, and built-in shell interpreter commands. Search set members, called keywords, are inserted into the structure only once, usually during program initialization, and are not generally modified at run-time.
Numerous static search structure implementations exist, e.g., arrays, linked lists, binary search trees, digital search tries, and hash tables. Different approaches offer trade-offs between space utilization and search time efficiency. For example, an n element sorted array is space efficient, though the average-case time complexity for retrieval operations using binary search is proportional to log n. Conversely, hash table implementations often locate a table entry in constant time, but typically impose additional memory overhead and exhibit poor worst case performance.
Minimal perfect hash functions provide an optimal solution for a particular class of static search sets. A minimal perfect hash function is defined by two properties:
* It allows keyword recognition in a static search set using at most one probe into the hash table. This represents the “perfect” property.
* The actual memory allocated to store the keywords is precisely large enough for the keyword set, and no larger. This is the “minimal” property.
For most applications it is far easier to generate perfect hash functions than minimal perfect hash functions. Moreover, non-minimal perfect hash functions frequently execute faster than minimal ones in practice. This phenomena occurs since searching a sparse keyword table increases the probability of locating a “null” entry, thereby reducing string comparisons.
User @rwmj earlier posted a link to GNU gperf. That's an implementation.
gperf's default behavior generates near-minimal perfect hash functions for keyword sets. However, gperf provides many options that permit user control over the degree of minimality and perfection.
If you have more of these on various subjects I would pay to read them. Perhaps make a book “Byte-size introductions to various CS topics” or some such.
Since the pandemic, I have been writing short articles aimed at lay people (not readers like you on HN), called Robots In Plain English. Link in my profile.
A different thing from what you were asking, but might be interesting to you.
Might he be including the regex compilation time?
His snippet literally shows the regex construction and call being in the same scope, by indentation level:
const std::regex txt_regex("(https)|(http)|(ftp)|(file)|(ws)|(wss)");
// later...
bool match = std::regex_match(v.begin(), v.end(), txt_regex);
The const won't help you here, what you want is static?Also, the parentheses aren't doing anything in that regex; and in many regex languages, parentheses are loaded with complicated semantics of group capture; I'd keep those out.
Another thing is that local static variables often compile to fairly horrific long code with branches. Better just move it to a wider scope or a longer-lived struct/class member somewhere.
Compressing regexes, I suspect, is a pointless excercise, except as a preprocessing pass before a pseudo-regex engine that does backtracking (e.g. Perl).
The GitHub issue comment is perhaps a bit high context. I wrote this reddit comment the other day that is lower context and probably more accessible: https://old.reddit.com/r/rust/comments/zsntov/pomsky_08_rele...
High level problem: general purpose regex engines that use finite state machines basically never build DFAs. Some of them will build lazy DFAs, but sometimes the lazy DFA can't or shouldn't be used. So you're stuck with an NFA simulation or bounded backtracking. And in those cases, getting rid of the alternation clog can be hugely beneficial (by orders of magnitude).
We got better performance with a linear scan and SIMD matching than with a hash table or a perfect hashing scheme.
See https://github.com/segmentio/asm/pull/57 (AMD64) and https://github.com/segmentio/asm/pull/65 (ARM64). Here's how it's used in the JSON decoder: https://github.com/segmentio/encoding/pull/101
“ No parent will name a favorite among their children. But I do have one among my brainchildren, my software contributions over the decades: The event-streaming code I helped build at AWS. After rage-quitting I missed it so much that over the last few months, I wrote a library (in Go) called Quamina (GitHub) that does some of the same things. This is about that.
Quamina offers an API to a construct called a “Matcher”. You add one or a hundred or a million “Patterns” to a Matcher then feed “Events” (data objects with possibly-nested fields and values) to it, and it will return you an array (possibly empty) of the Patterns each Event matches. Both Patterns and Events are represented by JSON objects (but it should be easy to support other Event encodings).
Quamina (and here I beg pardon for a bit of chest-pounding) is really freaking fast. But what’s more interesting is that its speed doesn’t depend much on the number of Patterns that have been added. (Not strictly speaking O(1), but pretty close.) ”
It's used by standard implementations of grep when matching against fixed strings (e.g. "grep -F" or "fgrep").
0: https://en.wikipedia.org/wiki/Aho%E2%80%93Corasick_algorithm
A simple Trie structure made of the set of strings would suffice.
But still probably would be slower especially if there aren't many misses that could be quickly rejected by the trie.
Putting that aside, what I suspect to be the most essential difference is the construction algorithm itself. Building an Aho-Corasick NFA (or even DFA) is done in worst case linear time. But building a DFA through powerset construction takes worst case exponential time.
Whether I'm actually right about that or not depends on whether the worst case bound for powerset construction remains as such when you know your input is just an alternation of literals. But certainly, if you take a general regex construction implementation and feed it a bunch of literals, the Aho-Corasick implementation is almost certainly going to come out on top. The former is more general and has more infrastructure that costs time and space.
The basic idea is: to use a bunch of hash tables with fixed-size keys but split the strings into power-of-two size-classes.
This basic idea originated in a chat and led to a PhD thesis by my friend: https://www.researchgate.net/publication/339879042_SAHA_A_St...
On the CDC 6600, word-size was 60 bits, and a character size of 6 bits -- so 10 characters in a 60 bit word. Then, compares become simple. The PDP-10 used 36 bit words and 6 bit encoding (ASCII space to underscore subtract 32, so space is 0, A is 33, _ is 63). Not so nice: "set of char" takes 64 bits -- and only 60 bits are available (using DEC SIXBIT encoding, or CDC DISPLAYCODE https://en-academic.com/dic.nsf/enwiki/11602432). I guess we put that down to -- can't win them all. These days, I would consider 7 bit, 18 characters in 128 bits, and "set of char" in 128 bits. Back then, 36 bits would hold 6 characters... and that could be a filename. Compare in one instruction on the DEC-10. Thus 6.3 names (filename, extension, and 18 more bits in 2 words).
I heard of 'gperf' before but it wasn't giving me good results so I had to hand write my own solution. I quickly noticed many of the keywords start and end with the same few letters but the second and third last rarely matched up and when they did the word length was different
I ended up writing `memcpy(&myint, (input_ptr+input_size-4), 4)` then adding size to myint. I then had a unique int so I used a switch and did a memcmp on each case. Memcmp was expensive because many words and keywords were the same length and 20+ letters. The function went from the slowest to fastest on that change. If I had to rewrite it today I would use SIMD and a 16bit compare
Anyway, this whole thing reminded me of FourCC codes, where you identify things with four-character ASCII strings, and freely reinterpret those as 32-bit integers whenever it's convenient:
https://github.com/nginx/nginx/blob/9c7a2c7ce4ad02a36df1bb0e...
You’re probably “safe” wrt segfaults with default memory allocators since memory blocks will be padded to 8 bytes, but a more compact allocator will expose the flaw.
On a related note, I vaguely recall that monkeying with allocator padding and alignment is a great way to suss out bugs like this. E.g. allocate all requested blocks of memory directly flush against the boundary of a decommitted page, forcing a segfault on even single-byte overruns. Wastes a lot of memory and forces slow unaligned memory access, but keeps you honest when justified.
"if you can tell your compiler that the string you receive is ‘padded’ so that you can read eight bytes safely from it."
But even if you can't make that guarantee, this still works pretty well as a performance comparison, you just need to make sure your final code has a runtime length check.
When I say "guarantee", I mean the code is designed so the first 8 bytes are always mapped.
When I say "runtime length check", I mean the code only runs if the first 8 bytes are in fact mapped.
No assumptions. No possible way to segfault. The read is valid.
If buffer[6] is not mapped, then you don't "legitimately have the buffer length".
Yeah, that's why I quoted the article.
if (input.size() == 5) {
return memcmp(input.data(), "https", 5) == 0;
}
if (input.size() == 4) {
return memcmp(input.data(), "http", 4) == 0 || memcmp(input.data(), "file", 4) == 0;
}
etcEdit: Nvm I see the issue with this, if you have a set like abcd1, abcd2, abcd3 then it has to try abcd first and then 1 and then abcd and then 2 and then abcd and then 3.
You could do sort of manual trie of full 8 byte chunks followed by a 4 byte step followed by a 2 byte step followed by a 1 byte step. But yeah suddenly it's not so clean anymore.
The example set doesn't have this issue though.
https://github.com/pkhuong/string-case/blob/master/string-ca...
The modern way to enforce that would be to write constexpr functions to do the conversions (or not? Are compilers required to evaluate constexpr expressions at compile time, where possible?)
No. But C++20 adds consteval, which does carry that requirement:
https://www.modernescpp.com/index.php/c-20-consteval-and-con...
Well, sort of. It has to produce a constant expression, and in practice the way to do that is to evaluate it at compile-time. But under the as-if rule, a compiler can do what it likes, up to and including producing a binary containing your source code and a copy of gcc, and compiling your entire program when you run it.
uint64_t protos[8] = {
string_to_uint64("https\0\0\0"),
string_to_uint64("http\0\0\0\0"),
string_to_uint64("file\0\0\0\0"),
string_to_uint64("ftp\0\0\0\0\0"),
string_to_uint64("wss\0\0\0\0\0"),
string_to_uint64("ws\0\0\0\0\0\0"),
0,
0
};
bool maybe_faster_is_special(std::string_view input, uint64_t protos[8]) {
__m512i vprotos = __mm512_load_epi64(&protos[0]);
__m512i vinput = __mm512_broadcastq_epi64(string_to_uint64(input));
__mmask8 comparisons = __mm512_cmpeq_epi64_mask(vinput, vprotos);
return (bool)(!_cvtmask8_u32(comparisons));
} vmovdqa64 zmm, m512 ; latency<=12
vpbroadcastq zmm, r64 ; latency<=5
vpcmpeqq k, zmm, zmm ; latency=3
kmovb r32, k ; latency<=3
The code is branchless. It will take the same number of cycles regardless of input. This is why it is `maybe_faster`. The faster version from the article may be faster for http/https because of short-circuiting behavior, but it would be slower for other protocols due to multiple branching.Aside from having greater throughput per instruction, being branchless means you will not have any branch prediction misses which negatively affect performance.
The vectorized version would be a significant improvement if there are many (more than 6) small strings to compare, but for this example the difference is probably minor.
Should the input be padded too? To avoid reading beyond the end of the string.
Also a trie could be faster for membership check.
If the overwhelming majority of words seen don't match, you would win by rejecting non-matches quickly. If instead it almost always matches, you will need to look at all the bytes anyway to be certain of a match.
If using a bloom filter with a small set, it's possible to obtain a low probability of false positives by using just one hash function and a small number of buckets. At that point you've effectively got a hashset like one of the solutions described in the blog post.
A separate hash table for what gets past would check for false positives, using the final hash value.