Live data from Hacker News

Optimizing Open Addressing

thenumb.at

31–40 of 70 posts

Re: Optimizing Open Addressing

#31
post #5

Earlier quoted context omitted.

In my experience / tests, its way easier to write a high-performance open-addressing Hash Table than a high-performance chaining one. That being said, chaining is easier to write. > Chaining also tends to waste a lot less memory. How so? A typical 32-bit integer or 32-bit float uses 4-bytes. But a typical pointer is 8-bytes. But there's also the internal malloc/new chunk header to consider. That means, to store a 4-b…

Nodes don't have to be allocated with malloc (that is actually the worst thing you could possibly do). An open-addressing hash table only performs well up to 50-75% bucket usage. Chaining doesn't care and performs the same up to 100%.

> (that is actually the worst thing you could possibly do)

Nonsense, there are many worse things you could do. For example, you could allocate nodes with mmap, at 4KB per node. (Which is a thing I've actually done in the context of cursed bootstrap code where malloc isn't available and having more than a handful of (in that case doubly-linked-list) nodes means the environment is deranged anyway.)

Re: Optimizing Open Addressing

#32

Sometimes a very basic fixed size chaining hash tables is actually the best you can do. Take for example tcc, which is a very fast c compiler. They just use a basic chaining hash table: `Node *nodes[16384];`. Since most translation units have far less than 16384 tokens, most lookups result in a direct hit.

This may result in poor CPU cache access pattern. For tasks that are often human-bound like source files or UI data structures using a sorted array can be a good option.

Re: Optimizing Open Addressing

#33
post #2

I find that there is too much of an emphasis on open-addressing in blog articles and such. There are a lot of real and common data structures where chaining is much better suited. For example whenever you have data part of one or multiple other node-based data structures, and you layer an index on top of that. Chaining also tends to waste a lot less memory.

Chaning still requires a flat table, which can change in size, and is proportional to the number of entries in the table (to keep the average chain length ("load factor") bounded). Thus, this can still cause memory fragmentation, even though the hash table chain nodes can all be the same size and so reduce fragmentation.

In terms of absolute bytes, ignoring fragmentation, it depends on the load factors.

In chaining, you need at least a singly linked list. Let's assume that our nodes store the hash value, as well as the key, value and a next pointer: so four pointer-sized words. (You don't have to store the hash code, but you'd be foolish not to, because it can reject mismatches in a single one-word comparison, avoiding the need to do a full key comparison.)

In addition, each chain needs a pointer-sized word which points to it from the table. (We ignore remaining overheads, like the small data structure which keeps the table and other bookkeeping info.)

Let's assume we keep the load factor (maximum average chain length) to 4. Beyond that we will increase the table size. So the chained table is considered 100% full with 4 nodes per chain on average. In this situation, the root table is amortized as 1/4 word overhead per node: so each node effectively 4.25 nodes.

An open-addressed table stores keys and values. To make the comparison fair, it should also store the hash codes; they are needed for the same reason: as we probe over collisions, we can use them to reject non-matching keys.

So, when it's 100% full, it needs 3 words per entry. In this situation, it's beating the 4.24 value of chained hashing. However, we would never want an open-addressed table to get 100% full, because the performance degrades, regardless of the collision resolution strategy. Say we allow up to 80%. 20% (0.2 x 3 words = 0.6 words per entry) of the table is wasted space, so 3.75. Still beating chained.

Now let's look at the half full situation. Below half full we might reorganize either table to be smaller, so that's our worst case.

When the chained table is half full, the average chain length is 2, and so each node needs 4.5 words: the table part represents more overhead, but the value doesn't change much from the maximum load case.

The half-full open-addressed table basically wastes 60% of the table: it's as if each item requires 7.5 words rather than 3. In this minimum load case, it is losing to chained hashing. (Remember, full is 80%, so half of that is 40%).

Summary:

   Words per entry*:
    
       \ Method
   Load \          Chained      Open Addressing
         +---------------------------------------
   half  |         4.5          7.5
         |
   full  |         4.25         3.75
         

   * Words are pointer-sized scalar values
   * For chained: full means load factor of 4; half means load factor of 2.
   * For open addressing: full means 80% of the table, half is 40% of the table
   * Assuming entries store hash code, key and value.
There is no clear winner in terms of memory, but it's looking as if the improvement from open addressing may not be that great, if at all realized. Of course, the values are debatable; why should the max load factor for chaining be 4? Or may be more than 80% can be crammed into an open-adressed table. I'm kind of surprised; according to the parameters I chose, I thought that open addressing would hit a better density.

What points in favor of the open addressing is caching behavior: avoiding the dependent loads of pointer chasing. For Open-Addressing, cache-friendly collision strategies can be chosen so when multiple entries are probed, they tend to be cached close together in the same block.

Re: Optimizing Open Addressing

#34
post #2

I find that there is too much of an emphasis on open-addressing in blog articles and such. There are a lot of real and common data structures where chaining is much better suited. For example whenever you have data part of one or multiple other node-based data structures, and you layer an index on top of that. Chaining also tends to waste a lot less memory.

In my experience / tests, its way easier to write a high-performance open-addressing Hash Table than a high-performance chaining one. That being said, chaining is easier to write. > Chaining also tends to waste a lot less memory. How so? A typical 32-bit integer or 32-bit float uses 4-bytes. But a typical pointer is 8-bytes. But there's also the internal malloc/new chunk header to consider. That means, to store a 4-b…

It is not necessary for an allocator to have any hidden overhead inside an allocated block. That would be quite unacceptable for small blocks like a 32 byte block holding four pointer-sized values.

In any case, applications can do their own aggregation for small allocations: allocate the nodes in an array and dole it out from that. It can recycle unused nodes itself.

Re: Optimizing Open Addressing

#35
post #29

Modern CPUs keep making optimal algorithms weirder. Speculative superscalar execution and the colossal gap between the CPU and memory speed means that often a brute-force solution that fits in a cache line wins over solutions that would feel more elegant or efficient.

To be fair, that’s been true of the past 20 years if not more.

Re: Optimizing Open Addressing

#36

Have you tried absl::flat_map? It uses simd in a different way than described in this article, and Google claims that it saves them a lot of memory because it still works pretty well at 90-95% occupancy.

I've benchmarked swiss tables and found that (for hit-heavy workloads) a minimum of 2 loads per lookup is expensive compared to 1.

I've really wanted to try making a hash table that works like a Swiss table but that stores the metadata interleaved with the rest of the data (16 metadata, 16 key, 16 value, repeat). doing so would keep your memory accesses closer together, but be a pain to program

Re: Optimizing Open Addressing

#37

Earlier quoted context omitted.

In my experience / tests, its way easier to write a high-performance open-addressing Hash Table than a high-performance chaining one. That being said, chaining is easier to write. > Chaining also tends to waste a lot less memory. How so? A typical 32-bit integer or 32-bit float uses 4-bytes. But a typical pointer is 8-bytes. But there's also the internal malloc/new chunk header to consider. That means, to store a 4-b…

It is not necessary for an allocator to have any hidden overhead inside an allocated block. That would be quite unacceptable for small blocks like a 32 byte block holding four pointer-sized values. In any case, applications can do their own aggregation for small allocations: allocate the nodes in an array and dole it out from that. It can recycle unused nodes itself.

> In any case, applications can do their own aggregation for small allocations: allocate the nodes in an array and dole it out from that. It can recycle unused nodes itself.

Or you could just write the open-addressing HashMap, which is easier than implementing your own custom small allocation malloc()/free()?

That's the thing. To make chaining competitive against open-addressing HashMaps, you've got to bend-over backwards and its suddenly not easy anymore. Though you're right in that different versions of malloc/free could be more efficient. Ex: tcmalloc() has small allocations built in already like you mention.

But even if you erase those 8-bytes from the glibc chunk implementation of malloc, you're _still_ 8-bytes over the open-addressing implementation (you can't get rid of the 8-byte "next" pointer). So all that work and you're still worse off than the other methodology.

Re: Optimizing Open Addressing

#38
Thanks for the great write-up! I have one quibble with your implementation of quadratic probing though: the usual index function used for quadratic probing is start_index + (i + i^2) / 2. This is the sequence you get by adding one to your start index, then adding two the next time, then adding three, etc., so you can avoid performing any actual multiplication by just adding one to the stride on every failed probe. Furthermore, this sequence has the useful property of visiting every index once before returning to the start, if your table size is a power of 2, so you could remove a check from your inner loop.

Re: Optimizing Open Addressing

#39

Thanks for the great write-up! I have one quibble with your implementation of quadratic probing though: the usual index function used for quadratic probing is start_index + (i + i^2) / 2. This is the sequence you get by adding one to your start index, then adding two the next time, then adding three, etc., so you can avoid performing any actual multiplication by just adding one to the stride on every failed probe. Fu…

I was actually wondering about that - it appears a (i+i^2)/2 sequence makes insertions (and by extension erases with rehashing) 7-10% faster, which is pretty significant. Lookups and probe lengths are about the same, so I think the conclusions stand.

Re: Optimizing Open Addressing

#40

Earlier quoted context omitted.

It is not necessary for an allocator to have any hidden overhead inside an allocated block. That would be quite unacceptable for small blocks like a 32 byte block holding four pointer-sized values. In any case, applications can do their own aggregation for small allocations: allocate the nodes in an array and dole it out from that. It can recycle unused nodes itself.

> In any case, applications can do their own aggregation for small allocations: allocate the nodes in an array and dole it out from that. It can recycle unused nodes itself. Or you could just write the open-addressing HashMap, which is easier than implementing your own custom small allocation malloc()/free()? That's the thing. To make chaining competitive against open-addressing HashMaps, you've got to bend-over back…

I can write aggregating allocation for a C with my eyes closed, in a tiny number of lines. These days I mostly wouldn't. If the target system thinks it's a good idea to add a header to every 32 byte allocation, that's their problem. No worthwhile malloc implementation does that, other than in a debugging configuration, where it adds "red zones".

The calculations in my other post show that it's not as simple as that next pointer being pure overhead compared to open addressing.

https://news.ycombinator.com/item?id=35416520

Post reply on HN