When Bloom filters don't bloom
blog.cloudflare.com
blog.cloudflare.com
https://github.com/cloudflare/cloudflare-blog/blob/master/20...
First, I used a hash function using aesni (aesenc) instruction set. See this:
https://gist.github.com/majek/96dd615ed6c8aa64f60aac14e3f6ab...
While I have little proof it's a good hash, it seems enough, and is _slightly_ (5-10%?) faster than siphash24 in this context.
Then I mixed counting hash with finding new lines \n. This allows me to do only one user-data load into XMM registers.
Most importantly, to offset the RAM latency cost, I'm doing 64 prefetches as I parse the input, and only after this I actually touch the hash table. The memory latency is still the biggest time sync, but at least this seem to speed up the program 2x or more. Hash table without this batching+prefetch is 6-8 seconds. With batching goes down below 3s.
I suspect linear probing / open addressing of the hash table may have some penalty. While it plays nicely with the cache prefetch, it generally leads to longer chains. This means we need to keep the hash table sparse, not loaded above 0.6-0.75. See this
https://en.wikipedia.org/wiki/File:Hash_table_average_insert...
Why not just use the 32-bit address as a key, and grow the 'blocks' so if two addresses are just a couple of digits apart, promote it to a /24 block etc.
I could indeed define data model, parse the data thoroughly, optimize in-memory data structure, and so on. That requires rigid data structure, knowing access pattern and understanding the problem space. I'm not there yet. Instead, I created this generic tool which works with any text files, and fell into a rabbit hole of over-optimizing it. That's it.
CIDR for ipv4 consists of the 32 bit address and a 32 bit mask, so with some bit packing you can uniquely represent them in 64 bits without hashing.
The problem you’ll run into there is doing a “contains” check on an origin IP for a list of CIDRs, but you’ll need to do that currently since you’re dealing with subnets, I assume.
Probably not as cache-friendly, but did you, at any point, evaluate Radix Tree [0] or Patricia Trie / CritBit Tree [1][2] as an underlying data-structure for the hash-table? These also compact nicely into one of the many succinct data-structures [3][4].
Radix Tree, in particular, seems to work really well for IPs from what I've read [5].
---
[0] https://vincent.bernat.ch/en/blog/2017-ipv4-route-lookup-lin...
[1] https://news.ycombinator.com/item?id=6920862
[2] https://news.ycombinator.com/item?id=3015246
[3] https://news.ycombinator.com/item?id=2348619
And a simple linear hash table instead of cookoo will also help in less cache misses. There are no deletions. Should be 20% faster, I think.
Or even gperf.
https://github.com/gamozolabs/falkhash nasm -f elf64 -o falkhash-elf64.o falkhash.asm
Thanks for spending time on this. I would like to understand what "really bad" and "fails most of the tests" means.
For the record, the commit: https://github.com/rurban/smhasher/commit/10f56385f3e9abb018...
The main point of this hash, in this context, is to do streaming hash and find \n at in one loop. The intention is to reduce data loads _mm_loadu_si128 (I already have user data in xmm0, so why not do some aesni already?). Because it's streaming I can't for example derive the initial seed based on the chunk length, since it's unknown at the time of calling hash. See:
https://github.com/cloudflare/cloudflare-blog/blob/master/20...
I don't need full aes hash, but maybe that could be an option as well.
In other words, in my case I don't care just about hash() speed. I care about memchr() + hash() speed. I would like to understand/measure the hash quality itself. Maybe adding another aesenc round would be sufficient to fix it.
For denser hash table, you need Robin Hood hashing.
[0] https://www.youtube.com/watch?v=ncHmEUmJZf4 [1] https://github.com/abseil/abseil-cpp [2] https://github.com/rust-lang/hashbrown
Edit: it will use more RAM than cuckoo filters.
If I skip two useless pipes, and use "sort -u logs.txt > /dev/null" instead, I'm already twice as fast as original (it seems that piping to sort effectively prevents parallelization).
Doing cat from left to right helps readability - it's important.
Performance-wise cat gives you 64KiB blocks of data, while direct pipe can give more. My programs (mmuniq-*) use 512KiB input buffer, so indeed with redirection you can reduce the number of read/write syscalls 8x, but it doesn't change much of the timing frankly.
Parallelization is an interesting aspect, which we didn't discuss really.
People may balk because it's unfamiliar, but this is syntactically legal:
< logs.txt sort | uniq > /dev/null
That is, the redirection customarily goes at the end, but it doesn't have to.EDIT: Also, in this specific case, the "sort" command can take a file argument, so you can also do this:
sort logs.txt | uniq > /dev/null LANG=C sort -u
but `sort -u` on its own is only marginally faster than sort|uniq.I can't remember exactly what LANG=C does, but I think the it makes sort not need to do some fancy unicode stuff? If the person writing the article just needs to uniqify IP addresses they should use it.
marek:~$ time (cat logs-popcount-org.txt | sort -u | wc -l)
39057531
real 2m37.387s
user 2m35.626s
sys 0m2.937s
marek:~$ time (cat logs-popcount-org.txt | LANG=C sort -u -S6G | wc -l)
39057531
real 0m12.908s
user 0m42.826s
sys 0m3.586sThis does run faster, but it's also important if you want a predictable order and to distinguish all strings like you would get if you implemented your own text sort naively.
From Googling just now: "sort -u doesn't report unique lines, but one of each group of lines that have equal sorting order. So if you do want unique lines, you need a locale where characters are byte and all characters have different sorting order (which the C locale guarantees)."
Probabilistic data structures are about trading off correctness and performance. If you try to push the correctness up to near perfect, they'll quickly stop making sense and you should just use an actual perfect algorithm instead, as the author did.
Bloom filters are great for early outs, where you can save a chunk of computation on a definite negative, but still be correct in case of false positive.
The rate of false positives you require out of the data structure is key. If your program is correct with a 20% false positive rate, you're golden. If the goal is more or less 0, look elsewhere.
The author addresses precision, but not in a way that questions whether a Bloom filter is indeed the right tool for the job.
I suppose they started with a bloom filter because they intended to use one when consuming the data, i.e. checking incoming requests against a 'malicious IP' set?
I also had an image in my mind, that bloom filters are perfect for such a use case - "set" data structure with some adjustable loss (probabilistic) parameters. I thought that Bloom filters are underappreciated. I was wrong. As we learned "set" is better done with good old hash table.
--edit--
Actually, it was a hextree (radix tree specialized for the hexes). Think someone posted a link to the paper on it a while back.
This is an assumption based on a very naive understanding how packets get delivered on the Internet. I for one wouldn't enjoy being blocked from CloudFlare sites just because of poor routing or peering.
https://en.wikipedia.org/wiki/Hot-potato_and_cold-potato_rou...
All it would cost is the excess runtime, which we should not mind giving up unless we smoke.
If necessary, you could have two or more. 256 of them would fit in 128G, which lots of servers have without even needing it all.
The implication is that the original poster used hashing out of a preference for wasting time, speculated as an excuse to go out for a smoke.
That's bytes, not bits.
Some of us do that, to take the latency hit just the one time: MAP_POPULATE.
IMHO this was critical information that muddled the article. I think most people who think critically about their data (ie, everyone who would be interested in the article) should have the same thought. I sure did.
Blush. However will I cope with all this extra traffic?
One common way to improve bloom filter cache performance is to divide them into blocks - elsewhere is mentioned doing it to the cache line, but it would be interesting to see how much performance would be gained with a more naive approach, for instance, splitting the filter into 4KiB pages.
I've done this for disk-backed filters, but never looked to see if it improved performance generally.
EDIT:
So, for instance, if k = 19, that means there are 19 hash functions. If he goes through all the inputs, and checks: are there any hashes here falling within the first 1/19 of the memory space? If not, keep it. If so, check whether any are unset. If not, keep it. If they are, zero the pointer to the input in the array. After this is done, he should be rid of roughly 32% (0.5 * (1-(18/19)^19)) of candidates. The second pass throws out 33% of candidates, and so on and so forth.
He could even keep an absurdly large value for k and n: if it is 128G, then k = 19'053, meaning he can use an even finer increment. He'd have to spill the filter to disk, but the access patterns will be great.
marek:~$ time (cat logs-popcount-org.txt | awk '!a[$0] {a[$0]=1; print }'|wc -l)
39057531
real 0m41.236s
user 0m38.179s
sys 0m5.447s
So: sort: 2m, awk 41 seconds. Also, awk used 6.1G of RAM at peak. marek:~$ cat logs-popcount-org.txt | perf stat -d awk '!a[$0] { a[$0]=1; print }' > /dev/null
Performance counter stats for 'awk !a[$0] { a[$0]=1; print }':
40,318.47 msec task-clock:u
0 context-switches:u
0 cpu-migrations:u
1,670,649 page-faults:u
112,979,634,215 cycles:u
93,441,976,758 instructions:u
18,990,099,679 branches:u
208,386,137 branch-misses:u
26,093,832,363 L1-dcache-loads:u
708,880,979 L1-dcache-load-misses:u
464,332,790 LLC-loads:u
245,913,835 LLC-load-misses:u
40.337768657 seconds time elapsed
36.851718000 seconds user
3.468126000 seconds sys
Compare this to the optimized approach which has 57M LLC-load-misses, and 7M instructions.After hiding latency, the next bottleneck would be instructions so vector scatter/gather can alleviate this problem.
There is also a command line utility that accompanies the library: https://github.com/nixer-io/nixer-spring-plugin/tree/master/....