Skip to content

HashFnv1a32 / HashFnv1a64

HashFnv1a32 turns a string literal into a constant at compile time. That is the one thing no other hash in Basalt can do, and it is what makes a switch over names possible:

switch (HashFnv1a32(stage_name))
{
case HashFnv1a32("vertex"): return ShaderStage::Vertex;
case HashFnv1a32("fragment"): return ShaderStage::Fragment;
case HashFnv1a32("compute"): return ShaderStage::Compute;
default: return ShaderStage::Unknown;
}

Every case label above is an integer in the binary — the compiler runs the hash, not your program.

FNV-1a is the weakest hash Basalt ships, and that is a deliberate trade, not an oversight. It exists for two jobs: a name known at compile time, and reproducing a value another system computed. It must never key a hash map — HashFast is the function for that, and the reason is below.

A string identifier costs nothing at run time if the compiler resolves it. HashFnv1a32 and HashFnv1a64 are constexpr, so anywhere a constant is legal, the hash of a literal is legal too:

constexpr u32 sky_material = HashFnv1a32("materials/sky");
static_assert(HashFnv1a64("a") == 0xAF63DC4C8601EC8Cull); // RFC 9923's own vector
// A table of assets keyed by name, resolved before main() runs.
struct AssetEntry { u32 name_hash; AssetId id; };
constexpr AssetEntry assets[] = {
{ HashFnv1a32("materials/sky"), AssetId::Sky },
{ HashFnv1a32("materials/water"), AssetId::Water },
};

The string-literal form is compile-time only, on purpose: HashFnv1a32("...") is an immediate call the compiler must evaluate, so a char buffer filled at run time will not compile through it. That closes a real trap — a half-filled char name[64] would otherwise silently hash 63 bytes, padding included. To hash bytes you have at run time, use the String8 form, which is the same function and the same values:

const u32 id = HashFnv1a32(Str8FromCString(name_from_the_file)); // run time, same value

Note that HashFnv1a32(Str8FromCString("literal")) is a run-time call: Str8FromCString() scans for the terminator and cannot run in a constant expression. Pass the literal directly when you want the constant.

Interop: reproducing another system’s value

Section titled “Interop: reproducing another system’s value”

HashFnv1a32 and HashFnv1a64 are RFC 9923’s FNV-1a, frozen for the life of the framework, and pinned in Basalt’s build by the RFC’s own published test vectors. Anything that emits an FNV-1a value emits one of these:

System What it hashes with FNV-1a
Roslyn (C#/VB) the synthesized hash behind every switch over strings — 32-bit
Unreal Engine FFnv::MemFnv32 / MemFnv64, UE::HashStringFNV1a
EnTT entt::hashed_string, i.e. every "..."_hs asset and type id — 32-bit by default
Kubernetes the pod-template-hash label that names a ReplicaSet — 32-bit
Go hash/fnv — New32a, New64a
MSVC’s STL std::hash for scalars and strings, internally

Two things decide whether your value matches theirs, and both are yours to get right:

  • What bytes go in. Basalt hashes bytes. Roslyn hashes UTF-16 code units, so reproducing a Roslyn value means feeding the same UTF-16 units, not the UTF-8 text.
  • Whether a terminating zero is included. String8 carries a size and never a NUL, so Basalt gives you RFC 9923’s string value. A C system that hashed strlen(s) + 1 bytes produced its block value — pass the zero byte yourself to match it:
HashFnv1a32(Str8FromCString("foobar")) // 0xBF9CF968 — the string
HashFnv1a32(Str8(bytes_with_nul, 7)) // 0x0C1C9EB8 — the string plus its NUL

For FNV the running value is the whole state, so continuing a hash is passing the previous value back in — the shape zlib’s crc32 uses:

u32 hash = FNV1A_32_OFFSET_BASIS;
while (ReadChunk(file, chunk))
{
hash = HashFnv1a32Extend(hash, chunk);
}

HashFnv1a32Extend(HashFnv1a32Extend(basis, a), b) is exactly HashFnv1a32(a ++ b), at every split point. Two consequences worth knowing:

  • The hash is not prefix-free: hashing "ab" then "c" gives the same value as "a" then "bc". If you fold several fields into one hash, fold in your own separator or length.
  • Start from the offset basis, never from a value you invented. The state 0 is absorbing: zero bytes folded into a state of 0 leave it at 0, forever. The offset basis never reaches 0, so nothing goes wrong unless you pick your own start.

FNV-1a does not avalanche, and not by a small margin. One multiply per byte cannot carry the last byte’s contribution to the top of the word, so the value’s entropy sits at the bottom and the top barely moves. Measured over all 256 one-byte inputs, the top byte of the 64-bit hash takes exactly one value while the bottom byte takes all 256; over 4096 three-byte keys the top byte still takes only 21 of 256.

An open-addressed hash map takes its slot index from one end of the hash and a fingerprint from the other, so a table built on this value hands every short key the same fingerprint and its probe chains collapse. Use HashFast for anything you look up. If you genuinely need a narrower value, fold rather than truncate — ((hash >> 8) ^ hash) & mask, the reduction FNV’s own author prescribes.

You need Use
A constant from a name, at compile time HashFnv1a32 / HashFnv1a64
To reproduce an FNV value another system computed HashFnv1a32 / HashFnv1a64
A key for a HashMap or any in-memory lookup HashFast
A 64-bit identity for content you persist HashRapidV3Nano

Parameters, assertions and the full contract are documented in basalt/core/hash/HashFnv1a.h.

Signature Purpose
HashFnv1a32(String8 bytes) The frozen 32-bit FNV-1a hash of the bytes. constexpr.
HashFnv1a64(String8 bytes) The same at 64 bits.
HashFnv1a32("literal") The hash of a string literal, evaluated at compile time, terminator excluded.
HashFnv1a64("literal") The same at 64 bits.
HashFnv1a32Extend(u32 previous, String8 bytes) Continues a hash over more bytes. Start at FNV1A_32_OFFSET_BASIS.
HashFnv1a64Extend(u64 previous, String8 bytes) The same at 64 bits, starting at FNV1A_64_OFFSET_BASIS.
FNV1A_32_OFFSET_BASIS, FNV1A_32_PRIME RFC 9923’s 32-bit constants.
FNV1A_64_OFFSET_BASIS, FNV1A_64_PRIME RFC 9923’s 64-bit constants.