Yesterday I was working on adding compile time hashing of strings to my game engine. I use string IDs for referencing assets and so far my asset systems API looks like this:
| Asset* font_asset = assetm->get("mono_font");
|
Pretty straight forward and working quite well. I have caching of baked assets and hot asset reloading working and all. Now I wanted to add compile time hashing, so I neither have the runtime overhead of hashing hundreds of string IDs each frame nor the additional complexity of hashing the strings once and using the computed hashes everywhere.
I wanted it to look something like this:
| Asset* font_asset = assetm->get(C_HASH(mono_font));
|
Where C_HASH(string) is magically replaced by the correct hash during compile time.
Of course I quickly concluded that a simple C preprocessor à la Handmade Hero is just what I need but (alas!) I thought, why not have a quick googling around to see what other people are doing. On the the pages of the all-beloved (especially around here) Stackoverflow I stumbled on a comment that said something about using C++11 constant expressions (constexpr) to compute hashes at compile time. So let's have a look, I thought to myself.
Using just the C++11 version of constexpr for hashing flew right out the window as it doesn't support loops in the constexpr function. So to compute the hash you have to basically do it in a pure functional style. I had so much common sense left that I knew: the C++ compiler interpreting (probably) thousands of function calls, that each recurse potentially dozens of levels deep can only _suck_. Maybe it doesn't, I haven't tried, but I strongly doubt it.
So I kept looking and found that in C++14 constexpr's supposedly got a big powerup as they now support loops and stuff. Cautiously hopeful, I decided to try them out.
And what do you know, the C++14 constexpr version of my string hashing functions looks exactly the same as the runtime version:
1
2
3
4
5
6
7
8
9
10
11
12 | constexpr static u32
_mk_const_hash_fnv1a32_null_terminated(const char* string)
{
u32 hash = MK_FNV32_OFFSET_BASIS;
while (*string)
{
hash = hash ^ (u32)(*string++);
hash = hash * MK_FNV32_PRIME;
}
return hash;
}
|
Great, now all we need is to write our little macro and we should be good:
| #define C_HASH(name) _mk_const_hash_fnv1a32_null_terminated( #name )
|
I make up a little test like this:
| mk_log("%u\n", C_HASH(test));
|
It compiles just fine. I jump into trusty gdb just to confirm everything worked alright but of course (probably to nobodys suprise around here)... it did not.
The hash function got executed at runtime. I double checked that I called the right version and did everything else correctly. I did. So I asked google:
I found this on
isocpp.org (emphasis by me):
Bjarne Stroustrup said on Jan 14, 2013 08:58 PM:
[...] according to the standard a constexpr function may be evaluated at compiler time or run time unless it is used as a constant expression, in which case it must be evaluated at compile-time. To guarantee compile-time evaluation, we must either use it where a constant expression is required (e.g., as an array bound or as a case label) or use it to initialize a constexpr.
WHAT? So compilers are just free to do whatever even if the constexpr could be perfectly computed at compile time because the _STANDARD_ says they
*may be* evaluated at compile time... This is beyond stupid!
So to ensure that everything runs at compile time we have to jump through more C++ standard committee hoops. We either have to do this everywhere we use a hash:
| constexpr u32 h = C_HASH(test);
mk_log("%u\n", h);
|
Which is unacceptable to me as I want to be able to call C_HASH wherever I could just type in the hash manually. It's also incredibly easy to forget. Or you have to create this little C++ abomination:
| template <u32 hash>
static inline constexpr u32 __attribute__((always_inline))
_mk_compile_hash()
{
return hash;
}
#define C_HASH(string) (_mk_compile_hash<_mk_const_hash_fnv1a32_null_terminated(string)>())
|
So that the compiler is forced to evaluate the hash function because its return value is used as a template argument. I have absolutely given up on the constexpr idea by now but I wanted to see just how horrible it's gonna be.
By the way you also have to still *force* the compiler to inline the _mk_compile_hash function or else you will be stuck with one function call per hash at runtime. Something which, as far as I know, you can't reliably do using MSVC.
Okay, so I assembled a little test suite that consists of (supposedly) every english word there is (brought to you by google). All in all, that's about 354984 words all lowercase.
Here's what it looks like:
| // 354979 more lines like the following go here...
mk_log("%u\n",C_HASH(zymurgy));
mk_log("%u\n",C_HASH(zythem));
mk_log("%u\n",C_HASH(zythum));
mk_log("%u\n",C_HASH(zyzzyva));
mk_log("%u\n",C_HASH(zyzzyvas));
|
Baseline:
With C_HASH defined as a constant (so no hash computing at all) I tested the compile time and size of the executable using clang on linux. This is just a quick test, btw. No scientific methods at work here.
C_HASH defined as constant:
Compile time: ~ 10.4s
Executable size: ~ 8.6MB
constexpr
Here are the values using C++14 constexpr as defined above:
Compile time: ~ 289.8s (4m 49.8s)
Executable size: ~ 53MB
Notice the super bloated executable. I assume this has to do with the fact that the compiler has to instantiate a template function per C_HASH call (the _mk_compile_hash function from above).
My own C preprocessor:
I then used about 2 hours to produce a simple c preprocessor (very similar to what Casey showed on HMH) to go through all my source files, look for the C_HASH identifier, take the ID name and produce a header file that looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | // NOTE(pp): Auto generated. Do not edit manually!
#pragma once
#include <stdint.h> // uintN_t, INTN_MAX
#define C_HASH(name) (_PP_UID_ ## name)
enum _PP_UID_ENUM : uint32_t
{
// about 355000 lines omitted
_PP_UID_zymurgy = 44216644,
_PP_UID_zythem = 1492189414,
_PP_UID_zythum = 2026410198,
_PP_UID_zyzzyva = 704117786,
_PP_UID_zyzzyvas = 2053265739,
};
|
As you can see C_HASH gets defined as an enum value. This generates optimal code (it just inserts the constant value) but it puts some pressure on the compilers preprocessor which we see in the slightly increased compile time compared to the baseline. Also: my preprocessor does collision checking for all the used string IDs in my codebase. Something I wouldn't have dared to try implement in C++14 constexpr's (would that even be possible?).
Compile time: ~ 16.9s
Executable size: ~ 19MB
In that compile time included are the ~0.5s my preprocessor takes to process the whole codebase (362 KLOC). It also had to process 13 fewer hashes than the constexpr version because those produced collisions and we don't want none of that.
Now the results aren't as dramatic if you take for example about 3000 string IDs (a more realistic number) rather than 350000. Some quick testing gave me these compile times:
Baseline: ~ 1.1s
My prepro: ~ 1.3s
constexpr: ~ 1.5s
But keep in mind that my preprocessor does collision checking for all produced hashes. It will also be super easy to add additional functionality (like generating code to look up the original names for each hash).
Still, I was quite shocked at how badly the constexpr stuff scales. I always thought people who wrote compilers would be able to make stuff go _fast_. The most shocking thing to me is, that the standard would say something as stupid as 'constexpr functions might be executed at runtime'. I guess in the programs these people write nobody is gonna notice if they take a second more or less at startup time...