Implementing Hash Tables in C
andreinc.net
andreinc.net
In certain scenarios open addressing has worse performance, but with the right optimisations it's generally only contrived situations where it performs worse. In normal usage, open addressing has vastly superior locality, a smaller memory footprint, and less dereferencing overhead.
A lot of writing and textbooks describing open addressing seem to imply that the order of walking through the table when collisions occur is really important (and it is). But the approach with best performance in practice is to just do a dumb increment through the table in-order because of caching... the penalty for that simple approach can be made up for with a good, pseudo-random hash.
Open adressing = When there is a collision, that is when the memory slot is already taken, you just put your key in the next one (linear probing), or at squared increment (quadratic probing) or at an increment determined by another hash function. Howewer all those techniques seems to be suffering from clustering and the more elaborate ones trying to trade memory locality for less clustering. I guess it just depends on how long and what scale you are going to need a hash table ?
Robin-hood hashing is to "steal from the rich" (aka: if someone is 2-away from their ideal spot, and you're at 5-away from your ideal spot, you take the slot and make the 2-away "worse").
So then the issue is space efficiency of direct addressing schemes. Here n-choice (2 is optimal actually) hashing provides for excellent loading at the cost of (precisely) 2 [n] cache line accesses. Your linear probing for an empty slots is likely to incur that on average if not worse.
Cuckoo hashing, imo, is a hybrid that wants the deterministic access times of direct addressing (2 locations on reads) at the cost of variable times on writes (which are not worst than pure open addressing).
SwissTable, which is generally among the fastest hash tables around, doesn't do this. It does chunks of 16 entries each, then starts skipping chunks (so first 0 chunks are skipped, then 1, then 2, etc.) The chunk skipping behavior helps avoid O(n) behavior on pathological cases.
Therefore, much like the situation I described in my parent post, SwissTable would benefit from being used with a decent pseudo-random hash. Which is no trouble, there are a number of good, fast ones.
0: 918782482972292171
1: 12294850989704472498
2: 5224175424757160222
3: 16600243932558888071
4: 9529568361303343081
5: 2458892796473463123
6: 13834961304296162488
7: 6764285737197172256
8: 18140354246076836247
9: 11069678680190000369
10: 3999003117507595883Rust's HashMap (which is a Swisstable reimplementation) chooses a SipHash 1-3 by default but you can drop in anything you want, just with the knowledge that if your hash is bad your HashMap will have lousy performance.
This is a good visual depiction of chained scatter: https://book.huihoo.com/data-structures-and-algorithms-with-...
Inspired by Lua, I did the same for upb (https://github.com/protocolbuffers/upb). I recently benchmarked upb's table vs SwissTable for a string-keyed table and found upb was faster in both insert and lookup (in insert upb is beating SwissTable by 2x).
It's true that the links do create more memory overhead though.
In reality computers actually have a memory hierarchy and no memory access is ever O(1). In RAM it only looks like constant access, because the memory address search is hardware optimized.
Many programming languages are heavily inspired by this academic pointer chasing and so their performance is pretty terrible, no matter how clever typing systems or compiler optimizations you add. It's all crap once you scale to a decent size of data and beyond one machine. The only thing that performs and transparently scales beyond one cpu are 1. array languages and 2. sql databases. Both of these actually understand things such as linear memory access, data-parallelism, the fact that cpus have caches and that 'memory' is a hierarchy of cache levels, RAM, disk, etc.
The random access model is usually taught first, because it works reasonably well in most situations. A slight variation of it, the cell-probe model, tends to be more useful for proving lower bounds. In that model, all computation is free and only memory accesses count.
A more practical variant of the cell-probe model is often called the external memory model. The computer is assumed to have M words of fast memory and an unlimited amount of slow memory that is accessed in blocks of B words. Computation is still free, and parameters M and B are both known to the algorithm. This model is useful in situations where accesses to one level of memory hierarchy dominate the running time.
The cache-oblivious model can be useful in situations where many levels of memory hierarchy matter. It can be understood as the external memory model with unknown values of M and B.
Another useful technique is parameterizing the complexity in terms of key operations. If operation X takes t_X time, we can then say than an algorithm takes O(|P| t_X log n) time with pattern P and problem size n.
Isn't that technically correct? The access time is bounded by a constant, hence the 1 -- unless there's a tape in your memory hiearchy. (That you always strive for a better constant is of course another matter.)
The fundamental idea of a hash table is that you turn the key into a hash value and then store the key in the bucket corresponding to the hash value. The simplest way of dealing with hash collisions is assuming that each bucket is a concrete data structure capable of storing multiple keys. With this conceptual model, you already understand the essence of how hash tables work and when it's the appropriate data structure to use.
Open addressing is a more advanced topic. Undergraduate data structure classes usually mention it, but many students don't have the prerequisites (such as probability and memory hierarchies) to understand it or to make informed choices about it. After all, basic data structures is an early class, typically taught after introductory programming but before the core CS classes that rely on it.
So dynamically switching between a linked list and a rb tree (or a trie) is quite practical and done in the real world, e.g. Java's HashMap
As for the 2nd consideration - it's not fancy, too easy to implement, no novelty, etc.
The way I have dealt with the downsides of linear probe, open address: not well spread out lower bits - smearing/rehashing the hashes of the keys (via murmur3). As a side benefit it effectively randomizes the iteration order.
Is the idea that you only use open addressing in cases where you know you’ll place much fewer items into the table than the number of bins?
This does mean that insertion time goes from a true O(1) to an amortized O(1), but that’s usually not an issue. (You can do the rehashing gradually if you really have to, but it’s probably not worth the complexity.)
I find that weird since my introductory textbook went straight for an open addressing, linear probing hash table -- probably because this approach is ancient. I'm not even sure a different approach was mentioned (but of course in that course, it was only one topic out of many to grasp).
[1] https://github.com/veorq/SipHash
[2] https://www.aumasson.jp/siphash/siphashdos_29c3_slides.pdf
If you have a specialized tree structure like a red-black tree which will automatically balance, this will improve, but sorted input is still a viable attack method.
That said, a lot depends on the application and one should pick a hash function wisely. A binary tree–mapped hash table works best when you want to be able to iterate on keys in sort order or find an entry that's just before or just after a non-existent key in sort order. It's not really a good defense against hash attacks. On the flip side, the default hasher in Rust is hardened against hash attacks, but it's overkill for most applications and can cause severe performance degradation (although at least the library documentation is clear on this and points at alternative hash implementations for such cases). Unfortunately, the Rust resizing algorithm is flawed and won't resize until the hash capacity is completely full¹ making hash collisions much more likely. I had a doubling of performance in benchmarking some code that I was writing just by increasing the initial capacity of the map.
⸻⸻⸻
1. A common mistake I see Java developers make is, when they know they'll be inserting n items into a HashSet or HashMap is to initialize the structure with a capacity of n. This guarantees that Java will end up re-allocating the index since the default load factor in Java is 0.55 meaning that once you have 0.55×capacity elements in your collection it will resize.
That's actually a bug in HashMap (either the implementation or the spec). If you ask a structure to reserve space for n elements, that means it should should reserve enough space to actually store n elements (so n/0.55 in this case). If the method actually does mean to set the internal, implementation-detail capacity rather than the real capacity, then the specification is buggy, because that's largely useless unless you're relying on implementation details (as demonstrated).
"When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets."
and it explicitly tells you that HashMap will default to 0.75 load factor, therefore the documentation is telling you that HashMap(400) is not a suitable container for 400 items but only up to 300 items.
Now, it's true that e.g. Rust's HashMap::with_capacity(400) is actually a container which promises it's suitable for 400 items‡ and that's more ergonomic, but I don't think we can call Java's choice here a mistake, it's just harder to use correctly.
‡"The hash map will be able to hold at least capacity elements without reallocating".
1. https://www.finl.xyz/2021/09/30/building-a-trie-in-rust-opti...
2. And since this won't be the only char-indexed map in the application, I will have to revisit this.
Which is the correct decision for lower size(s). I use dynamic fill factor with 1 being the default with <= 16 elements.
Yet again, very few cycles are spent on small hash table look ups in general. Yet, a lot of (like really a lot) is spent on small hash table wasted memory.
If you care about performance: measure, measure, measure... and know what you measure.
The "real" capacity and load factor of the underlying data structure is not presented to you. Swisstables (the current implementation) can't be filled entirely, they need an empty slot to function correctly. There is some trickery to ensure that very small Swisstables get to have an "empty" slot that doesn't really exist, and thus doesn't need RAM. Since you can't ever write to it the non-existence doesn't matter. A Rust HashMap::with_capacity(0) is guaranteed not to allocate any actual heap storage, just like String::new() don't need heap to store the empty string inside it.
The common mistake is using the c-tor allocating N size. The c-tor does way more harm than good. Most HashMaps in Java have few elements with tons of empty array space, many of them are pre-allocated to quite large sizes too. (At the very least nowadays the empty c-tor doesn't immediately allocate the Node[], so empty HashMaps are ok).
One of the strengths of HashMap is that resize/rehash is extremely fast/efficient and it doesn't dereference the keys (i.e. no hashCode/equals called). It doesn't reallocate Nodes, etc.
Naïvely this feels as though it should provide good performance in the common case and also good security - even if there are many primary hash collisions, they won’t also be secondary hash collisions. Does anyone know if there is an analysis of this approach anywhere?
Should probably just use a keyed hash function like siphash - the security analysis is straightforward and these hash functions are quite well developed.
That means that certain inputs will collide regardless of what the seed is. Seeding the hash function is no defense against such an attack.
Just out of curiosity, did you take the exam? Was the process satisfactory? Did you have a feeling that the test correctly represented participants' skills?
It is still worthwhile to manually perform these arithmetic optimizations because you may want to build with optimizations off and switching to actual multiplication in hot code paths can cause performance regressions.
An unrelated area where I wouldn't do this sort of thing is bit twiddling tricks to implement branchless code. That generally defeats modern compilers' abilities to generate branchless code with conditional moves and ends up being slower.
Why would you turn off optimisations if you were worried about performance optimisations?
The correct type is uint8_t/u8. Sadly, using that to alias other types is undefined. I've also seen hashing algorithms which used uint16_t/u16, with similar aliasing optimization bugs. Sometimes you want to reinterpret things as a struct, too.
This is the reason why Linux compiles with strict aliasing disabled.
uint8_t is not guaranteed to be unsigned char but in practice almost always is. GCC did originally have separate 8 bit types when stdint.h was introduced but quickly changed to a typedef for char-based types precisely to allow using it for aliasing.
Yes, technically char may not be 8 bits but in practice that is very rare (and you can statically assert it).
Overall IMO the best solution is always use uint8_t and turn off optimisations on those rare weird platforms where it's not an alias for unsigned char for whatever reason.
Does this imply any unsigned char typedef is able to alias anything? Or is uint8_t a compiler special case?
IIRC POSIX guarantees that char is 8 bits though (but I still think that the sign is implementation-dependent).
But as I said in a parent comment, I don't understand why it's even relevant. If you want to alias any type then use `char *` and not anything else. I don't understand why one would prefer using stdint for that.
This is independent of how many bytes the underlying platform can address. If we have 8 bit processing code but the platform can only address 16 bits at a time, it should be up to the compiler to generate code that works. Compilers already do stuff like that in other circumstances.
By definition `sizeof(char) == 1`, so that's almost always what you want when messing with types in C anyway. What you want is bytes, not octets.
If optimizations break code due to aliasing violations I would really recommend fixing the code, not turning the optimizations off!
Also if anything C's aliasing is less strict than Fortran by default, hence the later introduction of "restrict" to allow further optimizations.
Yeah, sometimes it's possible. Usually by making a mess of everything with unions. If I remember correctly, type punning with unions is still illegal C code but in practice every compiler understands the idiom.
The simple and intuitive solution to many problems is to cast the data to the new pointer and work directly with it. This should always produce correct code no matter what. People think like this and they write code with these assumptions in mind. In practice, nobody really cares too much what the C standard says. What matters is whether the compilers produce the desired code.
it disables signed integer overflow checks
High level programming languages have come a long way in the past 20 years. So many things we no longer need to think about in normal day to day programming
Plenty protect "Engineer" meaning "Software Engineer" is actually a title that you _can not_ hold.
As for the "mandatory courses on algorithms" and such if you go to university, definitely Germany. If you go to university and study computer science, the first few courses which you will have to pass before ever getting to choose your own courses are going to be about modelling, data structures and algorithms. You will learn the theory of hashing, you will learn how to model problems you will learn various sorting algorithms etc. in the lecture. Labs will make you implement various of these things on an actual computer. An exam will probably ask you to write one or two of these algorithms in pseudo code. Been there, done that. And of course comparing all manner of algorithms in Big-O notation etc. Also "reducing" one algorithm onto another (dunno if that's the proper term in English). But basically taking an algorithm that you know the run time of and showing that a different algorithm you have has the same characteristics and thus Big-O complexity. You will also learn about P/NP and will implement bin packing and such.
Unrelated to hash tables but hashing always reminds me of substring search algorithms that we learned about. The naive way is to just do a "text" search, advancing one character at a time and comparing the whole thing to your substring. I don't remember what the algorithm is called or who invented it but we learned about an optimization for substring search, which hashes with a particular hash function and when advancing a character in the string to search through, you reverse the part of the function for the first character and only add the result of the function for the next character. Thus you save having to deal with the characters that haven't changed at all.
Just like hash tables or linked lists, I've never needed to code any of these ever again and I just use them, but it was definitely worth learning about all this stuff.
The respective order validates that Software/Informatics Engineering degrees are actually engineering.
Additionally, for certain legal activities like signing contracts with liability, putting the Eng. so-and-so is only legally binding if the title was validated via the traditional admission exam.
Also you can be sued if lied about having a title, although since our prime minister got away with it, and legal system takes ages, probably no one would bother unless some big loss comes to be.
And if you so wish, can also get the ring.
We'll get to call software engineers actual engineers when they can lose their (currently not existent) license over malpractice, not a second before that happens.
The tech industry cheapens the term "engineer". The discipline isn't nearly enough mature to be called that
I’m not aware of any that even attempt to hold software engineers to any sort of Professional Engineer standard, or have a mechanism that one could actually comply if they did.
I do know of some areas where a PE is required for some software engineers, like some nuke or aerospace shops, but they’re very very few in number - and the PE isn’t about the software part of it.
Tis a technical blog, being pedantic about expression meanings is all the rage around these parts :)
Have they? How? It seems to me that we're not very far away from where we were. Yeah we're running on better tin, but the programming languages are pretty much the same - at least the mainstream ones are.
> So many things we no longer need to think about in normal day to day programming
I'm always surprised when I read comments like this. It's not necessarily wrong, but all programmers writing anything less than trivial will bump into limitations of the various types of data-structures eventually. Not understanding the trade-offs and not being able to make informed decisions about different approaches will be a problem. We may not have to write them ourselves, but we should certainly understand how they're implemented in my opinion.
I may be a special case in that I started programming on machines with tiny amounts of memory and with slow CPUs, and have spent time writing core data structures when in the games industry in the 90s, and now in my large web-application life have a large open-source library [1] which is all core data structures; but I honestly couldn't imagine being a successful developer without a key understanding of these things.
I agree. My argument is that they are no longer something everyone needs to know how to implement to be considered “can code at all”.
We have a hardware that's thousands, if not a million times faster, yet it hardly seems to matter when the code is a thousand, if not a million times slower.
One of my pet peeves about hash tables is that most of the resources about it describe how there are dozens of different ways to do it (different hash functions, different collision management, etc), but they often don't tell you which one you should choose.
My biggest "leaps" have been to spend time on code and get the general system I need in place and working, then consult with a more senior colleague on it. Typically, once I've gotten my work to a point where the intended system is clear enough and I have functional code, that's where a more senior review can first take place and:
1. The senior can immediately (hopefully) understand what our goal is here
2. The senior likely has some ideas on better ways to approach it than I did in the first iteration
3. If it does get to matters of opinion, at least I'm lucky enough that my senior is very unemotional about such things and explains why they like their way, but usually can understand why I did something the way I did
And after that, I iterate and I feel like I get the idea way better.
Trying to think about the "right way" from the beginning traps me a lot personally and almost scares me that I'm going to do something wrong. I also don't really feel I "get" why something is the right way until I've done it the wrong way or at least explored the wrong way a bit first.
But again, I'm pretty nascent so you might be talking on a different level. But I feel this experience applies to basically everything with code, and just getting your hands into bad implementations and being willing to rewrite is the best way to find that "right" way for you.
Look Java and python for example. The people working on the languages are quite smart, and they decided to take different routes. Who was right ?
There's the Attractive Chaos blog for you: https://attractivechaos.wordpress.com/
https://www.amazon.com/Algorithms-Computer-Science-Robert-Se...
I though it was much more than two times slower? Division and modulus are the slowest integer operation.
φ^2 - φ - 1 = 0
Which can be re-arranged, by dividing by φ to, φ - 1 - 1/φ = 0
or: 1/φ = φ - 1φ^-1=φ-1
The values are related by φ = 1/Φ, as well as Φ = φ + 1. Both of these relations can be derived from the fact that they are the ratios of a Golden Rectangle--if a square has length one, then adding a rectangle with height φ to the side will give a rectangle of length Φ, and the rectangles are Similar.
The result is that performance tends to be great up to about 95% full. The resize operation is a dumb double-and-rehash, but for most programs, the lower cache impact is a win.
Neat article otherwise. It reminded me of mappings to real numbers which I always found interesting during my undergrad.
However these are all techniques that I might have used in undergrad, but today I would hands down use Cuckoo hashing unless the application domain was insertion dominated.
Of course that might still be a reasonable trade-off to make. Is there experimental (or experiential) support for getting significant improvements from ordering?
It doesn't, you can order by hash value. However, it's a separate question on whether the hash value is stored with each item or not.
I have an implementation here [2] that is used for the HashMap and HashSet types in language-ext. The 'guts' of the implementation is here [3]
[1] https://michael.steindorfer.name/publications/phd-thesis-eff...
[2] https://github.com/louthy/language-ext/blob/main/LanguageExt...
[3] https://github.com/louthy/language-ext/blob/main/LanguageExt...
It might be unpopular to say this but: can we please stop teaching people to write new programs in C? The language is inherently unsafe, and we're collectively losing billions of dollars per year to preventable memory safety errors.
Why not use Rust or C# or something to demonstrate the principles of hash tables? Why start with the programming equivalent of a 1950s death crap automobile with a spike in the center of the steering wheel?
[1] https://github.com/rust-lang/rust/tree/master/compiler/rustc...
If a new hire is minimally competent at programming, I can bring them up to speed in whatever language it is we are using. Hell, even when people have notionally been taught a language in school, there's a decent chance I would still have to train them in that language as a new hire, because what they learned in school may be insufficient to cover the complexities that get involved in practice.
And to OP's point: C itself is a pretty poor language at covering what it actually covers. It has some missing features that just seem random (e.g., there are no 8- or 16-bit arithmetic operators in C). It has gratuitous undefined behavior (signed integer overflow and strict aliasing). As a language design, it's atrociously outdated (#include is a very poor substitute for modules, and generic programming might as well not exist in C). There are questionable features--null-terminated strings don't look like such a good idea in retrospect. Even if you want to avail yourself of its strengths--access to low-level hardware features--C's gratuitous undefined behavior (particularly strict aliasing) undercut it there, and languages like Rust and Zig give you the same power with none of C's warts.
C's job is to be the first thing ported to a new architecture, allow you to bootstrap a system, and build on top of it (last 2 steps optional if you're working on some sort of embedded system).
And, frankly, it's reasonably good at that. Writing a simple C compiler is trivial compared to something like rust.