Effortless Performance Improvements in C++: std:unordered_map
julien.jorge.st
julien.jorge.st
The issues I see are: a lot of string copying instead of string views, splitting strings into vectors rather than splitting on demand, replacing on the same string repeatedly and collecting into a string before adding it to another string. It seems like their benchmark data has a lot of small lines with set and value commands, which would not be majorly hurt by those issues.
The performance of ~20MB/s is REALLY SLOW for string processing in cpp. A fast but not super optimized c++ program should be getting in the ballpark of 500MB/s.
[0]: https://julien.jorge.st/posts/en/effortless-performance-impr...
But the author is clearly a highly-motivated intermediate C++ user who is taking time to share some key lessons and anyone who keeps that up for long won’t be intermediate for long!
So it’s still a cool blog IMHO :)
It’s backwards compatibility that kills them on performance which is why the 900-IQ C++ shops like Google etc. just go their own way on the hash table because it’s worth it to them to do the extra work.
I worked on optimizing that version for a little while. The result is a version which takes 30% of the time of the original and a little worse than twice as fast as the best in the repo. That's still disappointingly slow to me, but I'm not great at c++ so I don't really want to take the time to try to improve it further.
The operations on the strings are subtly expensive and I was expecting to see them above the accesses to the maps in the perf report. Now they were not and I address the problems in the order of this report, so we will have to wait a bit to discuss them. The intent is also to unroll a scenario of optimization for the reader: measure to get insight, try something, measure again, decide.
If you have the time and the motivation I would love to see a fast but not super optimized version of this program. I tried to keep using the standard library and not going into cryptic code (i.e. trying to keep the code clear for an average programmer), and I stopped when the performance gain became very small, but I am pretty sure we can get better performance if we push further.
Still pretty low-effort but slightly higher cost and benefit is to switch to more modern data structures that have to break a little backwards compatibility to get big wins. Things like Folly’s F14 and Abseil’s collections (Abseil has both faster hash tables and faster tree-style maps).
I think languages like Rust with a little less backwards compatibility burden are shipping with cache-aware and SIMD-exploiting data structures “out of the box” these days, but but my knowledge of e.g. Rust stops short of “read the standard library a bunch of times” so check with someone who has :)
Thus for example, the HashMap in Rust 1.0 is different from the HashMap shipped in any recent Rust, but they both implement the API described in the documentation, the new one is just much faster because it's actually HashBrown, a re-implementation in Rust of Google's Swiss Tables C++ hash map.
Hyrum's Law means that to some extent even though the defined API is narrow, people will write Rust code, by mistake or on purpose which depends on internals that were never guaranteed. For example, sort_unstable_by() is, as its name says, an unstable sort, you should not assume that it leaves ['a', 'A', 'B', 'b'] in the same order when you provide a case-insensitive comparison operator - you should have used sort_by() if you could not accept instability in your sort results.
Rust gets to measure the impact of Hyrum's Law and re-consider potential changes which have large real impact because it does Crater Runs, which compile and test large volumes of Rust software from the crates.io package site and GitHub, with to-be-released Rust versions. If you made a change that you thought was basically a typographical fix, and it's technically not a breaking change by Rust's rules but it blew up 15000 widely used crates, you need to think harder about whether that's OK and most likely revert your change.
C++ has no analogue of the Crater Run as of yet, and it offers a lot of needlessly broad APIs which lock it into choices that are terrible because it promised something that in hindsight nobody cares about, but now you daren't change that.
The ABI promises in real world C++ implementations make this even worse. Rust's Mutex<Option<CompactString>> [[CompactString is a 3rd party library type]] is significantly smaller than a std::mutex on most popular C++ platforms, and yet std::mutex is literally just a mutex, whereas Mutex<Option<CompactString>> is a mutex protecting an optional Small String Optimized string containing up to 24 bytes of UTF-8 encoded string all in line in your smaller data type.
It does in the form of large organizations with large codebases that try out candidate releases and file bug reports.
It's not managed by a central "C++ team" as such if that's what you mean, but there are N independent C++ implementations. At some point we're comparing organizational structure and not technology outcomes.
It's not that the previous practices didn't work, more or less, it's that they were manual and so you couldn't really rely on them. CI is categorically better, despite being in some sense just an automation of what should be existing practice.
The C++ compiler vendors I am aware of also have access to nontrivial C++ codebases that give them access to these sorts of "see what broke" speculative build jobs: Red Hat has RHEL and CentOS repositories, Microsoft has the presumably massive Microsoft codebases, everyone has access to vcpkg, and so on.
I'll also note that this is just normal for sufficiently popular systems languages. If Rust as successful as it seems like it will be, there will some day be far more code outside of public crates than inside them.
Anyway, there are some differences for sure, but it's not night and day they way you describe it.
There are legitimate use cases that require good performance and some ABI stability guarantees. Like various kinds of closed-source client libraries.
Rust doesn't have to serve those use cases, of course. But "stable ABI is bad" is a very broad statement.
The real problem here is the lack of packaging solutions for systems programming, which are frequently multi-language in some way. And in the case of closed-source use cases, that would look like binary packages. If we had quality dependency management, we would have different options for different use cases and standard libraries could focus on 90% use cases instead of 99.99% ones. Being all things to all users isn't a winning strategy.
std::map<std::string, std::string> m;
(void) m["nonexistent"];
m.size(); // => 1
Is there a historic reason for this strange specification? Or is it just a concession make the "operator=" sugar work better?operator[] returns a reference to a slot in the map which can be read from or assigned to. Since the reference must be valid, you better have allocated a slot in the map that can be written to if the value didn’t exist.
m["nonexistent"] = 1;
This would not work if you return a dummy value..find() is the safe way to do map lookup without side effects.
I mean, left side expressions and right side expressions are different
let salary = salaries["Jim Bob"]; // Note this panics if "Jim Bob" isn't a key in salaries, by analogy to arrays which panic if you do an out-of-bounds array access
However you can't write this, it won't compile:
salaries["Jim Bob"] += 10_000; // No raise for Jim Bob, this does not compile because that's not a mutable reference.
The ability to compute and pass around lvalue references (where "lvalues" are approximately "things that can appear on the lhs of assignment") is a very powerful feature, but also one which causes complexity in all sorts of other places, which is a bit of a theme for C++. One of those complexities is that non-const `operator[]` essentially has to be `operator[]=` even if at this specific call site there's no assignment happening.
C++ could have had const `std::map:::operator[]` throw an exception if the key isn't present, but having const and non-const versions of a function do different things is extremely error-prone and instead you have to use at() if that's what you want.
I tend to use find() when doing a read only lookup, and operator overloading for writing.
- what is the dummy value? does the value type even have one?
- I don't think index-assignment (or attribute-assignment) is overloadable in C++, so you need `a[b]` to return something which can be assigned to, and proxy values tend to be really wonky (see: `vector<bool>`), so assigning to the result of the indexing would replace the dummy value
In practice C++ APIs where you'd naturally write Option<&T> use pointers and are consequently easier to use wrong, this is an example of Rust actually providing improved safety technology (whereas many commonly identified safety differences like bounds checking in Rust are cultural differences)
The reason std::optional doesn't allow references is because someone in the commitee objected that some users might be confused by the assignment semantics (will assignment rebind or assign through the reference?) and the proposal authors dropped it instead of spending too long discussing pro and cons. It can still be easily re-added if the commitee wants to.
No need to drag the rust in the discussion.
You could easily hack std::optional<T&> specifically - and several people have, not just Boost - however C++ has a long history of "clever" hacked specializations that turn out to be disastrous so that isn't attractive. It's called generic programming for a reason.
The fact that it looks like you might be able to rebind or assign through the reference is a design goof. To the extent that std::optional is just the Boost type with a sticker on, that's a goof by the people behind the Boost type. However, they were hemmed in by inadequate language functionality, WG21 has the option to go fix the language, and they didn't do that, so the blame for that goes to them and not Boost. Having now standardised std::optional with the design goof, there is no attractive route forward.
The correct answer to "someone's" question by the way should be neither and that's the goof in std::optional, assignment to a Maybe type is assignment to the Maybe type you can't assign a Foo to a Maybe<Foo>, imagine if C++ also thought this ought to work for std::vector<int> ...
Far from being inadequate, it is pretty trivial to do so: https://gcc.godbolt.org/z/n4vK4jeaG .
You still need to special-case references because they are not actual C++ objects and need special handling.
Here's a question for you, what is the type of a do expression which just returns from the function you're in ? I mean, it's just an expression, we don't have to actually evaluate it we can ask what the type is... can't we ?
As far as I can see, C++ does not have a suitable type, which is why e.g. some C++ functions end up with a special attribute to signify that they won't actually return. I can see why this type would be unsettling, it's like when the ancient Greeks didn't like zero and so they initially refused to treat it as an integer. The type you want here is the additive identity.
Because the type system doesn't really understand additive identity, it's a struggle to write such a guarantee - not as a hack but in the type system.
Even if I did, I have no idea what you are trying to say.
edit: sorry that was a bit harsh. Can you try to explain what property is C++ missing exactly to implement the Niche optimization? I suspect you are trying to say that C++ lacks proper sum types (which is true), but that's really orthogonal to the optimization.
Over in Rust I really want to make BalancedI8, BalancedI16 and so on, types which are exactly the same as the ordinary signed integers of each size, except, their smallest possible value (e.g. i8::MIN ie -128) is gone (so BalancedI8::MIN is -127), leaving them nicely balanced between negative and positive, their abs() method doesn't have a hidden surprise - and of course this offers the smallest possible niche so Option<BalancedI8> fits in a single byte.
I obviously can in some sense do that today and I did -- the types are published, but today I can't use them from stable Rust, these types require a (deliberately permanently unstable) Rust-internal attribute telling the compiler about the niche, and they also can't be safely constructed (obviously I provide a safe function to make one, but you can't just conjure one into existence without calling the function, yes it gets inlined but that's not very ergonomic)
This is not a desirable state of affairs. As usual in Rust, the reason for the perma-unstable feature flag is that there's a germ of an idea of the Right Thing™ and so the eventual removal of the feature flag is predicated on obsoleting the feature by having the Right Thing™ in the language itself, not stabilization of the existing flag. Rust types which provide a niche will some day remove the feature flag and instead be defined using the language in the now-natural way, yet to end users nothing seems to have changed. The backwards incompatible change lives only inside private internals of the standard library so it's fine.
In this case that Right Thing turns out to be Pattern Types. What if we actually just tell the type system what's going on here about which values a type can actually have versus which ones are niches by providing a Pattern for the type. So it knows everywhere in the language that -128 may be a valid i8 but it's not a valid BalancedI8 because the pattern doesn't match.
However, Pattern Types might take quite a while to produce even a basic MVP implementation that can be stabilized, and so for a while I was pushing to get some way to just have what I want stabilized, sooner - without all of Pattern Types, and then replace it later using Pattern Types. But I was persuaded that actually this is poison, if you introduce my hack as a stable feature then it cannot ever actually be translated into a Pattern Type in user's code, so it becomes this weird orphan feature, "tialaramex's stupid niche type hack" forever even though all new types would use Pattern Types to achieve a much superior outcome, there's no known way to fix any of my hacked types.
So in effect you're making the arguments I was making like a month ago, that I now see are hopeless, which is why I'm asserting so vigorously that this is just a hack and what you should aim for instead is to explain to the type system what's going on even though that's harder.
The question about decltype ( do { return; } ) is pertinent because that type is to a type system like zero is to the integers in Z. I hope I spelled that correctly - I can't Godbolt it because do expressions are a speculative design, but hopefully you get the idea. This problem is easier to express with a do expression than with other C++ where the same problem arises but is harder to exhibit.
If rust devs do not want to add a specialization or traits for this feature and wait for the Right Thing it is fine. It is such a niche (get it?) requirement that it is not worth polluting the standard library.
But if you really need it as an user, I don't think the trait solution is an hack at all. I don't see specialization as an hack in general and in fact is important for writing efficient code.
Ideally you would be able to encode every possible property of your program elegantly and generically in the type system while keeping the language lean, fast and easy to understand. In practice at any point in time only a language will only have a finite subset of features and an user can't wait forever for the language to reach perfection. Any 50+ year language will necessarily accumulate cruft. Embrace it. Worse is better.
Yes but the earliest that could happen is, like, 2026 or perhaps 2027. Because you have to wait for both a new std revision to become available.
AFAIK basically just 3 wide-spread languages which do this, and they're all known for their idiosyncratic behaviour: C++, Perl, and PHP.
A few languages provide this as opt-in via a subtype (Python's defaultdict) or init (Ruby's Hash.new with a block parameter).
Some more will do it via an opt-in callsite API e.g. Python's `setdefault`, Java's `computeIfAbsent`, Rust's `entry` API.
my $x = $h{$v};
this is not safe because it autovivifies $h{$v} as an ARRAY ref: my $x = $h{$v}[$i];
with respect to idiosyncratic behavior, if a language doesn't have idiosyncratic behavior, it has no reason to exist; it's just gratuitously incompatible syntax for already-available semantics from collections import defaultdict
m = defaultdict(int)
m["nonexistent"]
len(m) # => 1
There are examples of the opposite too, though. Ruby's Hash does not insert the entry: m = Hash.new(0)
m["nonexistent"]
m.length # => 0It does if you initialise the hash with a block to do so:
m = Hash.new {|h, k| h[k] = 0}
m["nonexistent"]
m.length # => 1
That is, essentially, what defaultdict does. Just through subclassing (because `dict` does not provide such a fallback, but it has a magic method you can override to customise the mapping's behaviour in that case).if norvig isn't smart enough to use defaultdict without introducing bugs then i'm not either
(I am sure I never reported anything about the sudoku solver, since I never studied it.)
operator[] does more than lookup, but there are other functions for the other stuff. It should be pretty clear...?
Comparing to other languages I know, I couldn't think of another where collection[key] denotes insertion.
auto [it, newly_created] = m.try_emplace(key);
Does the same, and gives back more information. For something like `m[key] = value`, you can do `m.insert_or_assign(key, value)`, and save the default construction + assignment when the key was not there.For ordinary lookup you don't want to randomly mutate the map anyway. If your reference to the map is const, you can't use operator[] anyway, fortunately. However `m.at(key)` is not great either when the key not being present is really not exceptional, and the dance with `m.find(key)` is often not too ergonomic. I still suggest to just use `find` here.
It is just very convenient (hence the existence of defaultdict in python).
if(auto i = x.find(y); i!=x.end()){
// use i->{first,second}
}
Also ideally you should be able to do this: for(auto [key, value]: x.equal_range(y)) {
}
But infuriatingly, x.equal_range returns a pair instead of some range view so you need some additional infrastructure (either convert the result of equal range into a range or add some begin/end overloads for pair).The equal_range solution looks nice from a genericity perspective (look ma I do multimaps!) but is really unintuitive with regular maps imo. Unless I'm mistaken it also does not allow an else branch for when the value is not there, which is often needed.
Hopefully one day we will get proper patter matching and destructing in if statements (Herb Sutter has made significant progress on this).
cpp2 is both interesting and ambitious, I really hope it gets somewhere.
OTOH, try...else I find myself missing at times.
Especially annoying when looking up something in a const& map.
The alternative approach could have been to make operator[]= and operator[] different.
foo[bar] += baz;
... needs to provide operator+= with a mutable reference to foo[bar], and a maybe immutable reference to baz.
https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...
Which is actually better than C++ in more than one way. As a library author, you overload a+b, and you get a+=b for free. As a user, you know that a+=b always means the same thing as a=a+b (except for side effects) regardless of the library.
Similarly, you can't overload && and || in C# directly like you can in C++, but instead you overload "operator true" and "operator false", and you get the boolean ops consistently derived from that (which means that they can short-circuit properly when overloaded, unlike in C++).
salaries["Jim Bob"] += 10_000;
Calls the getter on salary to look up "Jim Bob", it finds maybe he's currently on 80_000, returns 80_000, we add 10_000 to that, getting 90_000 and then we call the setter on salary asking it to write 90_000 to "Jim Bob". Two lookup operations.It's also deeply unfortunate here that C# thinks "null" is both a plausible value for common types and a good way to distinguish lookup failure. So it's possible either that Jim Bob's salary literally is null for some insane reason or that Jim Bob wasn't on the payroll, and the effects here are identical.
The extra lookup would not be acceptable in C++ or Rust, though that feels much more reasonable in a language like C# or Go -- paying a little performance to buy nicer ergonomics. The needlessly dangerous API shape on the other hand is very C++ and ought not to exist in C#.
But, yes, it is true that you end up needing double lookup when combining compound assignment with indexing.
Lots of C# APIs do, in fact return null from Index (ie the [] operator) for "Not found", you're clearly writing much newer .NET code and so you're using a modern generic container which can't do this (because its return type can't be null)
> nor does it allow it by default even for reference types (assuming that you're compiling with nullability checks on, which has been the default for years)
My emphasis. I believe the default you're talking about starts in .NET 6 in late 2021. I don't think "since late 2021" qualifies as "for years".
C# Code I write from scratch does have nullability checks because I'd prefer to be in a language where "Null reference" isn't a thing, and this is as close as C# gets - but I maintain tonnes of code that was written three, five, even ten years ago.
I stand corrected on the timeline for nullable reference types being the default - for some reason I remembered it as being on by default for new projects since VS 2019.
C++ simply generalized this model to all collections, in line with the overall "better C" design. But then for maps it means that [] has to proactively create an entry if you want []= to work like it does everywhere else.
Similarly, C++ iterators are generalized C pointers, which is also not an ideal abstraction in practice (and why few other languages do it that way).
And: how often does this even matter? I'd guess very rarely.
In contrast your typical std::unordered_map has indifferent performance with a good hash versus a terrible hash, anything vaguely resembling a working hash will perform similarly (not very well).
Remember "all the defaults are wrong" in C++. That's not literally true, but more often than not, if there's a clownishly obvious mistake possible, C++ made it and you will need to explicitly work around it in your C++ program. In this case, there's no avalanche behaviour in most out-of-box hashes for C++. So the hash for 1, 2, 3 and 4 are extremely similar, and indeed in many cases they turn out to be 1, 2, 3 and 4 respectively. Not a good hash.
[1] https://stackoverflow.com/questions/38304877/why-stdhashint-...
$ python
>>> [hash(x) for x in range(4)]
[0, 1, 2, 3]Turns out for this structure hash quality just doesn't matter very much. The identity function is all you need for mediocre results, and a really great hash produces only slightly improved results with this data structure, yet running the hash costs performance so why bother ?
But a modern hash map structure actually relies on that quality for reasonable performance.
https://martin.ankerl.com/2022/08/27/hashmap-bench-01/#bench...
Ask that table about std::unordered_map - notice the fancier hashes make it worse (bigger numbers are worse, relative to 100 is the best) but not that much worse. It's always poor, it just varies as to whether it's very poor.
Now, ask it about absl::flat_hash_map - for Abseil's own hashes the results range from very good to mediocre, and likewise other modern high quality hashes, but for std::hash it's so awful most benchmarks don't finish, shown as - instead of a number.
You can try to detect this, but this is a fundamentally a QoI problem. Why even provide this rubbish hash function in the standard library? This is a lesson the C++ standard library refuses to learn, if you can't do it well then don't become an attractive nuisance by doing it badly.
People are actually less unhappy with Microsoft's stdlib which gives you a poor FNV hash instead of the identity function. Like, OK, this isn't a great hash, but it does basically work (under non-adversarial conditions, FNV has big problems for tailored input) something like Swiss Tables will be substantially slower than it ought to be, but not crazy benchmark failed due to timeout slower. Nobody is cheering, but it doesn't cause open weeping.
Then it's the good choice - I'm pretty confident the only reason std::hash is explicitly in the standard library is because it's the only way they could find to enable people to specialize hash functions for their own custom types - checking when it was introduced (C++ TR1: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2005/n18...), std::hash is in a subsection of the "unordered containers" section and is really shown as an implementation detail of those (that has to be user-customizable).
If you use another hash table implementation, likely you're also going to choose a hash function that works well with it - I use Ankerl's in my own code for instance which 1/ comes with a modern hash implementation, wyhash, and 2/ obviously I specialize for the really specific cases I need performance for with the known statistical properties of my inputs. But I wouldn't want to pay for this cost for std::unordered_map
Some of my favorite alternative hash map implementations are ska::flat_hash_map and ska::bytell_hash_map from https://github.com/skarupke/flat_hash_map. They're fast, and the single header implementation makes them easy to add to a project. For my use cases they generally offer similar performance to abseil and folly F14.
Don't be fooled by the fact that they haven't been updated in ~5 years. I've been using them for nearly that long and have yet to find any bugs.
Check this one out, it's a successor to this idea. Boost also introduced a very performant flat_hash_map in 1.81
The XOR operator?
This post doesn't think so: https://stackoverflow.com/questions/5889238/why-is-xor-the-d...
https://github.com/python/cpython/blob/71db5dbcd714b2e1297c4...
It made a huge difference in one of the programs I was running.
EDIT: I could have sworn they used to also support the ordered versions, but maybe not.
If so, that sounds fairly limiting to me (i.e. potential extra storage - of duplicates, sorting required, etc).
But yes, you either need to stop occasionally and remove the duplicates or use a set, if the storage is prohibitive.
Otherwise in terms of performance, sorting and unique is much faster than inserting one element at a time in a set.
- Hashing can be slower than comparison (or vice-versa), depending on the input. (e.g., imagine if your keys are multi-gigabyte files - hashing them would require reading all of their contents, whereas comparing might only need to read the first few bytes.)
- Ordered maps guarantee good worst-case performance. Unordered maps only guarantee average case. Usually you don't need to care, but with adversarial input you might.
- Ordered maps can guarantee low latency (well, to the extent your allocator can). Unordered maps can't - they suddenly block your program until the whole table is rehashed. Again, usually you don't need to care, but in some situations you might.
- Ordered maps have stable iterators; unordered maps "only" have stable pointers.
- Ordered maps have a guaranteed iteration order that depends solely on their contents; unordered maps don't.
- Any time the ordering itself is useful (see sibling comment)
But that's very unlikely, and in such a contrived scenario it's likely doing a linear std::find() over an array of keys would be as fast or faster anyway, due to better caching/pre-fetching/branch-prediction.
There’s no random access for hash map, in the sense of “give me the fifth element”. If you mean a lookup, like map[key], hash map has an O(1) amortized look up, where as a binary tree has an O(log n) look up, so hash map will generally be faster in this regard.
This is easy to do in all sane hash map designs, and is very fast in a linear design like Abseil's Swiss Tables.
Also sadly C++ std::unordered_map is guaranteed to be not just a hash table but an open hash table, using buckets to keep all the stuff which collided together. This is probably not what you wanted, but too bad that's what was standardized.
std::map actually seems to fit this role better. It works reasonable well for many workloads and types without tuning a bunch of parameters.
It's not that fast, because branching is completely unpredictable when iterating the sparse array (though a very high load factor helps). That's why a few modern hash tables trade an additional indirection on lookup by storing entries in a dense array and making the sparse array an array of indices into that.
It makes iterating the map as efficient as iterating an array (or zipping two depending on data layout), it also saves some memory (especially if the indices array is adaptive), and has the neat side-effect of making the map conserve insertion order (though deletion is a bit of a concern, tradeoffs may have to be made with the other advantages).
For a general purpose hash map, I think promising to preserve order despite deletion (like Python's current dict) is a mistake in performance terms†. Python could afford a penalty here because their previous dict was so ludicrously bad that you won't notice. We can't know if the user of a general purpose hash map does 0% deletes or 50% deletes nor whether the ordering is valuable to them at all anyway.
† If we don't promise to preserve order then delete just swaps the removed item from the dense array with the last item, then removes the last item, very efficient, if we insist on preserving order we're either using tombstones which mean branches or we're shuffling half the bloody array for each deletion.
Complexity is not a measure of runtime. The performance drawbacks for std::map have to do with cache, not O(log) vs O(1). log(billion) is 30. And that's exactly how many comparisons you have to do, not a constant multiple of that.
With an open addressing hash table, who knows how many steps you will need to find an element. It's even more questionable if it does linked list chaining.
> binary search tree.
It's usually a red black tree and maybe could be a b-tree? "Search tree" is accurate.
IIRC the complexity requirements of std::map preclude a b-tree (or require a binary tree). Though I don't remember the exact reasoning.
abseil's website makes the same claim, though also without justification:
> B-trees have a different implementation from STL std::map containers, which require binary trees commonly implemented as red-black trees.
I mean, I haven’t write red black trees for a while, but it is a type of binary search tree, as far as I remember, isn’t it?
map is definitely not better for random access, with the possible exception of adversarial input: IIRC std::map pretty much requires an rbtree so you get an O(log2(n)) access, while unordered_map is a hashmap so has an O(1) best case but O(n) worst case.
unordered_map might have a better cache friendliness (though that's debatable as IIRC the standard requires closed-hashing), however iterating it is a long series of unpredictable branches (each bucket can be empty or full and the better your hash function the more random it is), std::map requires a lot of pointer-chasing instead but it's all completely predictable.