diff --git a/.gitignore b/.gitignore index 1ba0a83..8502140 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ compile_commands.json # Generated Doxygen output docs/html/ docs/latex/ +docs/doxygen-warnings.log # benchmark results bench_results.csv \ No newline at end of file diff --git a/README.md b/README.md index 0d3e925..71996e5 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,28 @@ This README is the documentation index and recommended reading path. - Forward-looking roadmap for portability, core tests, CLI utilities, huge file streaming, and HTTP client support. -3. Doxygen API reference +3. [docs/HASHMAP_ALGORITHMS.md](docs/HASHMAP_ALGORITHMS.md) + - Standalone walkthrough of the algorithms used in the arena-backed hash map. + - Covers Robin Hood probing, cached hashes, growth via rehash, and set specialization. + +4. [docs/CORE_MODULES_GUIDE.md](docs/CORE_MODULES_GUIDE.md) + - Guided overview of the foundational modules and how they connect. + - Useful when moving from architecture notes into concrete APIs. + +5. Doxygen API reference + - Rendered landing page: [docs/html/index.html](docs/html/index.html) + - Guided authored-doc entry page: [docs/DOXYGEN_MAINPAGE.md](docs/DOXYGEN_MAINPAGE.md) - Generated output: [docs/html/index.html](docs/html/index.html) - Configuration: [docs/Doxyfile](docs/Doxyfile) +### Rendered Docs Structure + +The rendered Doxygen site now uses a dedicated Markdown landing page so authored design notes and API reference live in one navigation flow. + +- Landing page source: `docs/DOXYGEN_MAINPAGE.md` +- Rendered main page: `docs/html/index.html` +- Additional authored guides under `docs/` are included automatically by Doxygen. + ### Contributor and project policy 1. [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) diff --git a/docs/ARCHITECTURE_DECISIONS.md b/docs/ARCHITECTURE_DECISIONS.md index 644a67d..6c3dd0b 100644 --- a/docs/ARCHITECTURE_DECISIONS.md +++ b/docs/ARCHITECTURE_DECISIONS.md @@ -192,3 +192,81 @@ Trade-offs: - `core_portability` owns compiler and standard-library gaps, not OS allocation APIs. This split is deliberate: compiler portability and operating-system portability are related concerns, but they are not the same layer and should not be collapsed into one module. + +## ADR-0005: HashMap Uses Arena-Backed Robin Hood Open Addressing + +- Status: Accepted +- Date: 2026-07-13 +- Scope: Base hash container design for map and set workloads + +### Context + +The project needs a low-level hash container that matches the existing arena allocation model and stays efficient for Advent of Code style workloads. + +The main constraints are: + +- no per-element heap allocation; +- explicit failure reporting via `Result` rather than exceptions; +- compatibility with both key-value maps and key-only sets; +- predictable probe behavior under moderately high load factors; +- acceptable performance for both integer keys and byte-string slice keys such as `Str8`. + +Separate chaining would introduce extra pointer chasing and allocator pressure. A traditional open-addressing table without probe balancing risks long clusters and unstable lookup costs as load increases. + +### Decision + +The hash container design uses arena-backed open addressing with Robin Hood insertion. + +The current shape is: + +1. capacity is always a power of two; +2. indexing uses a bitmask rather than modulo; +3. keys, values, cached hashes, and occupancy control bytes are stored in separate arrays; +4. growth allocates new arena storage and reinserts live entries into the larger table; +5. `HashMap` is the set specialization and does not allocate a values array; +6. operations report failures through a dedicated `HashMapE` error enum and `HashMapR` aliases. + +### Why we need this + +- Robin Hood probing reduces variance in probe lengths and improves worst-case behavior at a given load factor. +- Separate key, value, hash, and control arrays preserve a simple data-oriented layout and match the existing arena model. +- Cached per-slot hashes avoid repeated re-hashing of resident keys during probe walks, which matters for `Str8` keys. +- Arena growth through allocate-and-rehash keeps ownership simple and avoids per-entry lifetime bookkeeping. +- A `void` specialization provides true set semantics without paying for unused value storage. + +### Consequences + +Positive: + +- No general-purpose heap allocator dependency in the hot path. +- Predictable insertion and lookup behavior for the supported workload shape. +- String-key probe chains avoid unnecessary repeated hash computation. +- Map and set behavior share one conceptual implementation strategy. + +Trade-offs: + +- Growth is monotonic within an arena and does not reclaim prior table storage. +- The current design does not yet support erase. +- The current control-byte scheme is intentionally simple and does not yet use SIMD-friendly fingerprints. +- The fast path assumes trivially copyable key and value types are the intended primary workload. + +### Alternatives considered + +1. Separate chaining with linked nodes. +- Pros: simpler deletion semantics. +- Cons: worse locality, more pointer chasing, and allocator pressure. + +2. Linear probing without Robin Hood balancing. +- Pros: simpler insert logic. +- Cons: longer-tail probe behavior under load. + +3. Recompute hashes on every probe step. +- Pros: less storage overhead. +- Cons: avoidable cost for non-trivial key types, especially `Str8`. + +### Follow-up actions + +- Add focused container tests for insert, overwrite, contains, and rehash behavior. +- Decide whether erase belongs in this container or in a separate higher-level variant. +- Evaluate SIMD-friendly metadata or fingerprint control bytes if probe performance becomes a bottleneck. +- Document supported key/value type expectations more explicitly in API docs. diff --git a/docs/CORE_MODULES_GUIDE.md b/docs/CORE_MODULES_GUIDE.md new file mode 100644 index 0000000..bcd4d2d --- /dev/null +++ b/docs/CORE_MODULES_GUIDE.md @@ -0,0 +1,146 @@ +# Core Modules Guide + +This document connects the core modules at a higher level than the generated API pages. + +The goal is to answer a simple question: + +- if you are trying to understand or extend the base layer, what should you read, and in what order? + +## The Core Story + +The base layer is built around a small set of modules that each own a distinct concern. + +At a high level: + +1. `core_types` defines the project's scalar vocabulary. +2. `core_vm` owns operating-system virtual memory boundaries. +3. `core_portability` owns compiler and standard-library portability shims. +4. `core_memory` builds the arena allocator on top of those lower layers. +5. `core_result` and `core_result_s` define explicit success/failure return types. +6. `core_string` provides `Str8`, formatting, and string-centric primitives. +7. `core_hash` defines hashing traits over supported key types. +8. `core_hash_map` builds the arena-backed hash containers. + +## Suggested Reading Order + +### 1. Scalar and utility base + +- [core_types module API](module__core__types.html) +- [core_types file API](core__types_8cppm.html) + +Read this first if you want the project's common integer aliases, arithmetic helpers, and shared low-level vocabulary. + +### 2. Portability and OS boundaries + +- [core_vm module API](module__core__vm.html) +- [core_vm file API](core__vm_8cppm.html) +- [core_portability module API](module__core__portability.html) +- [core_portability file API](core__portability_8cppm.html) + +These modules separate two kinds of platform concerns: + +- OS-level VM behavior belongs in `core_vm`. +- Compiler and language-model gaps belong in `core_portability`. + +That split matters for understanding how the arena remains simple while still being correct. + +### 3. Arena allocation + +- [core_memory module API](module__core__memory.html) +- [core_memory file API](core__memory_8cppm.html) +- [Arena type page](structbase_1_1mem_1_1Arena.html) + +This is the allocation foundation used by most higher-level transient data structures in the project. + +If you are reading the hash table, you should understand the arena first. + +### 4. Result types + +- [core_result module API](module__core__result.html) +- [core_result file API](core__result_8cppm.html) +- [core_result_s module API](module__core__result__s.html) +- [core_result_s file API](core__result__s_8cppm.html) + +These modules define the project's explicit error-reporting style. + +The hash map uses this pattern directly through `HashMapE` and `HashMapR`. + +### 5. Strings and slices + +- [core_string module API](module__core__string.html) +- [core_string file API](core__string_8cppm.html) +- [Str8 type page](structbase_1_1str_1_1Str8.html) + +`Str8` is one of the most important data types in the project. It is both a common runtime string representation and one of the supported hash-key types. + +### 6. Hash traits and containers + +- [core_hash module API](module__core__hash.html) +- [core_hash file API](core__hash_8cppm.html) +- [core_hash_map module API](module__core__hash__map.html) +- [core_hash_map file API](core__hash__map_8cppm.html) +- [base::hashmap namespace](namespacebase_1_1hashmap.html) + +This is the natural point where the earlier modules come together: + +- the arena provides storage; +- result types provide failures; +- strings provide one important key family; +- hash traits bridge key types to the container; +- `core_hash_map` provides map and set behavior. + +## How the Pieces Fit Together + +### Arena and HashMap + +The hash container does not own heap allocations directly. + +Instead: + +- `core_memory` supplies arena-backed arrays; +- `core_hash_map` stores keys, values, hashes, and control bytes inside those arrays; +- growth means allocate-and-rehash, not per-entry relocation with independent ownership. + +### Result and HashMap + +The container follows the same explicit-error style as the rest of the codebase. + +- low-level helpers return `HashMapR`; +- failures are categorized by `HashMapE`; +- callers are expected to branch on success rather than rely on exceptions. + +### Str8 and HashMap + +The `Str8` type is a natural fit for this design because it is a non-owning slice. + +That means the table can store the slice cheaply, provided the backing bytes outlive the table entry. + +## If You Are Focused on HashMap + +The fastest path through the core layer is: + +1. [HashMap Algorithms](HASHMAP_ALGORITHMS.md) +2. [Architecture Decisions](ARCHITECTURE_DECISIONS.md) +3. [core_memory file API](core__memory_8cppm.html) +4. [core_hash file API](core__hash_8cppm.html) +5. [core_hash_map file API](core__hash__map_8cppm.html) + +That gives you: + +- algorithmic intuition, +- design rationale, +- allocator model, +- trait model, +- and final implementation details. + +## If You Are Extending the Base Layer + +Use this rule of thumb: + +- add OS-facing behavior to `core_vm`; +- add compiler or standard-library shims to `core_portability`; +- add allocation policy to `core_memory`; +- add shared data representation to `core_string` or similar domain modules; +- add container-specific logic to the container module itself. + +Keeping those boundaries sharp is what makes the rest of the documentation readable. \ No newline at end of file diff --git a/docs/DOXYGEN_MAINPAGE.md b/docs/DOXYGEN_MAINPAGE.md new file mode 100644 index 0000000..3d95f2a --- /dev/null +++ b/docs/DOXYGEN_MAINPAGE.md @@ -0,0 +1,94 @@ +# AOC Documentation Guide + +Welcome to the rendered documentation entry point for this project. + +This page exists so the generated Doxygen site is not only an API dump. It provides a guided path through the authored design notes and the generated API reference. + +## Recommended Reading Order + +### 1. Architecture decisions + +- [Architecture Decisions](ARCHITECTURE_DECISIONS.md) +- Durable project decisions for arena allocation, portability boundaries, and hash-container design. + +### 2. HashMap algorithm guide + +- [HashMap Algorithms](HASHMAP_ALGORITHMS.md) +- Tutorial-style walkthrough of Robin Hood hashing, cached hashes, growth by rehash, and the `HashMap` set specialization. + +### 2.5. Core modules guide + +- [Core Modules Guide](CORE_MODULES_GUIDE.md) +- High-level map of the foundational modules and how memory, strings, results, and hash containers fit together. + +### 3. Future roadmap + +- [Future Enhancements](FUTURE_ENHANCEMENTS.md) +- Follow-up ideas for the hash container, platform work, tests, streaming, and other infrastructure. + +### 4. Contributor guidance + +- [Contributing](CONTRIBUTING.md) +- Project rules, coding expectations, and workflow guidance. + +### 5. API reference + +- Use the generated file, module, namespace, and type navigation in the Doxygen sidebar and index pages. +- The hash container implementation itself is documented in [core_hash_map.cppm API](core__hash__map_8cppm.html). +- The generated module page for the container is [core_hash_map module](module__core__hash__map.html). +- The generated namespace page is [base::hashmap namespace](namespacebase_1_1hashmap.html). +- The generated type pages are [HashMap](structbase_1_1hashmap_1_1HashMap.html) and [HashMap](structbase_1_1hashmap_1_1HashMap_3_01K_00_01void_01_4.html). + +## Documentation Layers + +This project intentionally uses two complementary documentation layers. + +### Authored guides + +These are Markdown documents under `docs/`. + +They answer questions like: + +- Why was this design chosen? +- What algorithmic tradeoffs matter here? +- What should future work improve? + +### API reference + +These are generated from Doxygen comments in source. + +They answer questions like: + +- What fields and functions exist? +- What arguments and return values do they use? +- What invariants or preconditions apply? + +## Current Highlights + +- Arena allocation is explicit and low-level. +- Portability and VM boundaries are separated into dedicated core modules. +- The hash container uses Robin Hood open addressing with cached hashes. +- Hash-set behavior is provided through `HashMap`. + +## Quick Links Into Rendered API + +If you want to jump directly into generated reference material, these are the most useful entry points: + +- [Arena API](core__memory_8cppm.html) +- [String API](core__string_8cppm.html) +- [Result API](core__result_8cppm.html) +- [Hash traits API](core__hash_8cppm.html) +- [HashMap file API](core__hash__map_8cppm.html) +- [HashMap module API](module__core__hash__map.html) +- [HashMap namespace API](namespacebase_1_1hashmap.html) + +## Navigating HashMap Material + +If your focus is the hash container specifically, the fastest path is: + +1. [HashMap Algorithms](HASHMAP_ALGORITHMS.md) +2. [Architecture Decisions](ARCHITECTURE_DECISIONS.md) +3. [core_hash_map module API](module__core__hash__map.html) +4. [HashMap type page](structbase_1_1hashmap_1_1HashMap.html) + +That sequence gives you the algorithm, then the design constraints, then the exact implementation surface. \ No newline at end of file diff --git a/docs/Doxyfile b/docs/Doxyfile index 3a72f35..103a43a 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -894,7 +894,7 @@ EXTERNAL_TOOL_PATH = # messages are off. # The default value is: NO. -QUIET = NO +QUIET = YES # The WARNINGS tag can be used to turn on/off the warning messages that are # generated to standard error (stderr) by Doxygen. If WARNINGS is set to YES @@ -997,7 +997,7 @@ WARN_LINE_FORMAT = "at line $line of file $file" # specified the warning and error messages are written to standard output # (stdout). -WARN_LOGFILE = +WARN_LOGFILE = docs/doxygen-warnings.log #--------------------------------------------------------------------------- # Configuration options related to the input files @@ -1230,7 +1230,7 @@ FILTER_SOURCE_PATTERNS = # (index.html). This can be useful if you have a project on for instance GitHub # and want to reuse the introduction page also for the Doxygen output. -USE_MDFILE_AS_MAINPAGE = +USE_MDFILE_AS_MAINPAGE = docs/DOXYGEN_MAINPAGE.md # If the IMPLICIT_DIR_DOCS tag is set to YES, any README.md file found in sub- # directories of the project's root, is used as the documentation for that sub- @@ -2251,7 +2251,7 @@ USE_PDFLATEX = YES # The default value is: NO. # This tag requires that the tag GENERATE_LATEX is set to YES. -LATEX_BATCHMODE = NO +LATEX_BATCHMODE = YES # If the LATEX_HIDE_INDICES tag is set to YES then Doxygen will not include the # index chapters (such as File Index, Compound Index, etc.) in the output. diff --git a/docs/FUTURE_ENHANCEMENTS.md b/docs/FUTURE_ENHANCEMENTS.md index 1bc8aa0..874113d 100644 --- a/docs/FUTURE_ENHANCEMENTS.md +++ b/docs/FUTURE_ENHANCEMENTS.md @@ -9,6 +9,44 @@ This document tracks proposed improvements for portability, testing, and core pl - Add features behind clear interfaces so Linux-first optimizations remain available. - Prefer incremental, testable milestones over large one-shot rewrites. +## 0. Hash Container Follow-Ups + +### Goals + +- Harden the new arena-backed hash container. +- Preserve its current fast path while closing obvious functionality gaps. +- Keep map and set usage aligned under one implementation strategy. + +### Proposed milestones + +1. Container correctness coverage: +- Add focused regression tests for insert, overwrite, contains, and reserve-triggered rehash. +- Add coverage for both `HashMap` and `HashMap`. +- Exercise integer keys and `Str8` keys. + +2. API completion: +- Decide whether to add `get_or_insert`, `find_or_default`, or similar convenience APIs. +- Decide whether `find_ptr(...)` remains the primary lookup surface or whether a higher-level wrapper is needed. +- Clarify whether mutation through returned pointers is a supported public contract. + +3. Erase strategy: +- Either add tombstone-based erase semantics to the current table, +- or explicitly keep this container insert/lookup-only and introduce deletion elsewhere. + +4. Metadata optimization: +- Evaluate compact fingerprints in control bytes for faster negative lookups. +- Evaluate grouped probing or SIMD-assisted metadata scans if benchmarks justify the added complexity. + +5. Arena lifecycle guidance: +- Document expected growth behavior when repeated rehashes occur in long-lived arenas. +- Decide whether a rebuild API should support caller-managed arena snapshots for bulk temporary maps. + +### Success criteria + +- Container behavior is documented and regression-tested. +- The public API is explicit about supported operations and failure modes. +- Performance changes are benchmarked rather than assumed. + ## 1. Portability and Cross-Compiler Support ### Why this matters @@ -197,11 +235,12 @@ Current direction: ## Implementation Order Recommendation -1. Core tests expansion (improves safety for all later work). -2. Portability abstraction for memory and compiler attributes. -3. CLI utilities (immediate developer ergonomics). -4. Huge file streaming support. -5. HTTP client utilities. +1. Hash container follow-ups. +2. Core tests expansion (improves safety for all later work). +3. Portability abstraction for memory and compiler attributes. +4. CLI utilities (immediate developer ergonomics). +5. Huge file streaming support. +6. HTTP client utilities. ## Open Questions diff --git a/docs/HASHMAP_ALGORITHMS.md b/docs/HASHMAP_ALGORITHMS.md new file mode 100644 index 0000000..eeb0add --- /dev/null +++ b/docs/HASHMAP_ALGORITHMS.md @@ -0,0 +1,623 @@ +# HashMap Algorithms: Robin Hood Hashing, Arena Allocation, and a HashSet for Free + +This document explains the algorithms used in this project's hash map and its set variant. It is written as a standalone guide, not as API reference. + +The goal is to explain not just what the code does, but why this particular design is a good fit for a low-level, arena-backed Advent of Code codebase. + +## The Problem We Want to Solve + +We want a hash container with these properties: + +- fast lookups and inserts; +- no per-node heap allocation; +- predictable memory layout; +- compatibility with the project's arena allocator; +- support for both key-value maps and key-only sets; +- explicit error handling through `Result`. + +That combination points strongly toward open addressing rather than separate chaining. + +In a separate-chaining table, each bucket points to a linked structure or small side container. That makes deletion easier, but it also introduces pointer chasing, branchy traversals, and more allocator pressure. Those are all costs we would rather avoid in this codebase. + +Instead, this project uses an open-addressed table with Robin Hood insertion. + +## The High-Level Shape + +At a high level, the table is four parallel arrays: + +- keys; +- values; +- cached full hashes; +- control bytes describing whether a slot is empty or full. + +The set variant is the same table without the values array. + +That means the set is not a separate algorithm. It is the same probing strategy with one column removed. + +## Why Open Addressing Fits Arena Allocation + +Arena allocators are excellent when you want: + +- a small number of large allocations; +- no per-object frees; +- simple ownership rules; +- bulk reset at a larger lifetime boundary. + +Open addressing fits that model naturally. + +When the table grows, we do not mutate individual entry ownership. We allocate a new, larger group of arrays from the arena and reinsert the live entries into that new storage. The old storage remains in the arena until the arena itself is reset or freed. + +That gives us a simple rule: + +- growth is allocate-and-rehash, never in-place node surgery. + +This is a good trade when the arena lifetime already matches the workload lifetime. + +## Power-of-Two Capacity and Bitmask Indexing + +The table capacity is always normalized to a power of two. + +That gives an efficient initial bucket computation: + +```text +index = hash & (capacity - 1) +``` + +This is equivalent to modulo when capacity is a power of two, but it is cheaper and it simplifies wraparound arithmetic during probing. + +The same trick is used to compute probe distance and to advance to the next slot. + +## Open Addressing Refresher + +In open addressing, all entries live inside the table itself. + +To insert a key: + +1. Compute its hash. +2. Convert that hash into a home slot. +3. If the slot is occupied, probe to another slot. +4. Continue until you either find the key or find a place to store it. + +To look up a key: + +1. Compute the same hash. +2. Start from the same home slot. +3. Walk the same probe sequence until you find the key or prove it is absent. + +The interesting question is what kind of probing policy to use. + +## Why Robin Hood Hashing + +This table uses Robin Hood hashing. + +The central idea is simple: + +- when two entries compete for a slot, the entry that is farther from its home position gets priority. + +If the incoming entry has probed farther than the resident entry, the incoming one steals the slot and the resident entry continues probing. + +That sounds aggressive, but it has a useful effect: it reduces variance in probe lengths. + +Instead of letting some unlucky keys drift very far while other keys stay close to home, Robin Hood hashing tends to equalize displacement across entries. In practice that gives more stable lookup behavior than plain linear probing at similar load factors. + +This is the source of the name: keys that are “poor” in probe distance steal from “rich” keys that are still close to home. + +## Probe Distance + +Every occupied entry has: + +- a home slot determined by its hash; +- a current slot where it actually lives; +- a probe distance, which is how far it had to move from home. + +With power-of-two capacity, wrapped distance is computed as: + +```text +distance(slot, home) = (slot + capacity - home) & (capacity - 1) +``` + +That formula is cheap and handles wraparound correctly. + +## Insertion Step by Step + +The insertion algorithm used by the map version works like this: + +1. Compute the incoming key's full hash. +2. Start at its home slot. +3. If the slot is empty, store key, value, and cached hash there. +4. If the slot contains the same key, overwrite the value. +5. Otherwise compare probe distances. +6. If the resident entry is closer to home than the incoming entry, swap them. +7. Continue probing with the displaced entry. + +The set variant is the same algorithm, except there is no value to store or swap. + +One way to think about it is that insertion carries a moving candidate entry through the table until it finally finds its resting place. + +## Early Exit During Lookup + +Robin Hood hashing gives a useful lookup optimization. + +Suppose you are searching for a key at probe distance $d$. If you arrive at a resident entry whose probe distance is smaller than $d$, the searched key cannot appear later in the probe sequence. + +Why? + +Because if that key had existed, Robin Hood insertion would have forced it to steal from the smaller-distance resident earlier. + +So lookup can stop early in two cases: + +1. it hits an empty slot; +2. it reaches a resident entry whose displacement is smaller than the current search displacement. + +That is one of the cleanest benefits of Robin Hood probing: insertion policy creates stronger lookup invariants. + +## Why We Cache Full Hashes + +The first version of the table recomputed resident hashes during probing. + +That is acceptable for tiny integer keys, but it is a poor trade for larger or more expensive key types like `Str8`. During insert or lookup, a long probe chain could repeatedly re-hash the same resident keys. + +The current design caches the full hash for every occupied slot. + +That helps in three ways: + +1. the resident home slot is derived from cached hash, not recomputed from key bytes; +2. equality checks can use hash equality as a cheap prefilter before key comparison; +3. rehash during growth can reuse the stored key with one normal insertion path. + +This is not the most compressed metadata design possible, but it is a strong performance improvement for a small complexity increase. + +## Why Separate Arrays + +The table stores keys, values, hashes, and control bytes in separate arrays instead of one array of large slot structs. + +There are trade-offs here. + +Advantages: + +- clear, regular layout; +- easy omission of values in the set specialization; +- cheap scans over control bytes or hashes in future optimizations; +- fewer mixed-field loads when only metadata is needed. + +Costs: + +- multiple parallel arrays must stay in lockstep; +- insertion and swap logic must update several arrays together. + +For this project, the design is a good fit because it keeps the set specialization simple and leaves room for future metadata-focused optimizations. + +## Why the Set Is `HashMap` + +Instead of writing a second independent hash-set implementation, the project uses a partial specialization: + +- `HashMap` stores keys and values; +- `HashMap` stores only keys. + +That means the set variant inherits: + +- the same hashing traits; +- the same probe strategy; +- the same growth rules; +- the same error model. + +The only major difference is that successful lookup returns a key pointer instead of a value pointer, and insertion does not carry or overwrite a mapped value. + +This keeps behavior aligned across the two container shapes. + +## Load Factor Choice + +The current implementation targets an 80% load factor. + +That is a practical middle ground: + +- lower load factors waste memory but reduce probe lengths; +- higher load factors improve memory efficiency but can lengthen probe chains. + +Robin Hood hashing usually tolerates moderately high load factors better than naive linear probing, but there is still a real cost as occupancy rises. Around 80% is a common compromise for a general-purpose fast table that is not yet using more advanced metadata tricks. + +## Growth by Rehash + +When the next insertion would exceed the load threshold, the table grows. + +The growth flow is: + +1. choose a larger power-of-two capacity; +2. allocate fresh arrays from the arena; +3. initialize all control bytes to empty; +4. reinsert every live entry into the new storage; +5. publish the new arrays. + +If allocation or reinsertion fails, the code restores the arena offset snapshot so a failed growth attempt does not silently leak arena space. + +That rollback behavior matters because arena allocators are intentionally simple. If a failed reserve advanced the arena cursor permanently, even an error return would still damage future allocation capacity. + +## Failure Model + +The container reports failures through: + +- `HashMapE`, the error enum; +- `HashMapR`, the result alias. + +That matches the broader project style: + +- no exceptions; +- explicit success and failure states; +- lightweight error categories. + +The two main operational failures today are: + +- out-of-memory during reserve or growth; +- probe exhaustion, which should be exceptional if the table invariants hold. + +## Usage Examples + +The following examples show the intended calling style for the current API. + +They are not trying to hide the low-level nature of the container. The design is explicit on purpose: + +- callers provide an arena; +- callers handle `HashMapR` directly; +- lookups return pointers rather than copies. + +### Example: `HashMap` + +This is the simplest map case: integer keys and integer values. + +```cpp +import core_types; +import core_memory; +import core_hash_map; + +using namespace base; + +void example_map() { + Arena arena = {}; + bool ok = arena.init(MB(1)); + BASE_ASSERT(ok); + + HashMap counts = {}; + + auto init_r = counts.init(arena, 32); + BASE_ASSERT(init_r.is_ok()); + + auto ins_r = counts.upsert(arena, 42, 1); + BASE_ASSERT(ins_r.is_ok()); + + auto overwrite_r = counts.upsert(arena, 42, 99); + BASE_ASSERT(overwrite_r.is_ok()); + BASE_ASSERT(overwrite_r.value == false); // existing key was updated + + auto find_r = counts.find_ptr(42); + BASE_ASSERT(find_r.is_ok()); + BASE_ASSERT(find_r.value != nullptr); + BASE_ASSERT(*find_r.value == 99); + + auto contains_r = counts.contains(7); + BASE_ASSERT(contains_r.is_ok()); + BASE_ASSERT(contains_r.value == false); + + arena.free(); +} +``` + +### Example: count-with-default pattern + +Because lookup returns pointers, a common pattern is: + +1. look up the key; +2. mutate in place if present; +3. insert if absent. + +```cpp +import core_types; +import core_memory; +import core_hash_map; + +using namespace base; + +void bump_count(Arena& arena, HashMap& counts, u64 key) { + auto find_r = counts.find_ptr(key); + BASE_ASSERT(find_r.is_ok()); + + if (find_r.value) { + *find_r.value += 1; + return; + } + + auto ins_r = counts.upsert(arena, key, 1); + BASE_ASSERT(ins_r.is_ok()); +} +``` + +This is one of the main reasons the current API exposes pointer-based lookup rather than only returning copied values. + +### Example: `HashSet` via `HashMap` + +The set specialization uses the same table but stores keys only. + +```cpp +import core_types; +import core_memory; +import core_hash_map; + +using namespace base; + +void example_set() { + Arena arena = {}; + bool ok = arena.init(MB(1)); + BASE_ASSERT(ok); + + HashSet seen = {}; + + auto init_r = seen.init(arena, 64); + BASE_ASSERT(init_r.is_ok()); + + auto first_r = seen.upsert(arena, 1234); + BASE_ASSERT(first_r.is_ok()); + BASE_ASSERT(first_r.value == true); + + auto second_r = seen.upsert(arena, 1234); + BASE_ASSERT(second_r.is_ok()); + BASE_ASSERT(second_r.value == false); // already present + + auto contains_r = seen.contains(1234); + BASE_ASSERT(contains_r.is_ok()); + BASE_ASSERT(contains_r.value == true); + + arena.free(); +} +``` + +### Example: `HashMap` + +String-slice keys are supported through the project's `HashTraits` specialization. + +The main thing to remember is that `Str8` is a non-owning view. The bytes referenced by the key must outlive the table entry. + +```cpp +import core_types; +import core_memory; +import core_string; +import core_hash_map; + +using namespace base; +using namespace base::str; + +void example_str8_map(Arena& arena) { + HashMap word_counts = {}; + + auto init_r = word_counts.init(arena, 16); + BASE_ASSERT(init_r.is_ok()); + + Str8 hello = Str8::from_cstr("hello"); + auto ins_r = word_counts.upsert(arena, hello, 1); + BASE_ASSERT(ins_r.is_ok()); + + auto find_r = word_counts.find_ptr(Str8::from_cstr("hello")); + BASE_ASSERT(find_r.is_ok()); + BASE_ASSERT(find_r.value != nullptr); +} +``` + +In real parsing code, `Str8` keys often point into input buffers that already live for the duration of the table, which makes this a good fit for arena-scoped workloads. + +### Example: custom key type via `HashTraits` + +If your key type is not one of the built-in specializations, add a `HashTraits` specialization in `base::hash`. + +The key rule is consistency: + +- if `eq(a, b)` is true, `hash(a)` and `hash(b)` must be the same. + +```cpp +import core_types; +import core_hash; +import core_hash_map; +import core_memory; + +using namespace base; + +struct Point { + u32 x; + u32 y; +}; + +namespace base::hash { + template <> + struct HashTraits { + static constexpr u64 hash(const Point& p) { + // Pack x/y and run the same integer mixing path used elsewhere. + u64 packed = (static_cast(p.x) << 32) | static_cast(p.y); + return detail::mix64(packed); + } + + static constexpr bool eq(const Point& a, const Point& b) { + return a.x == b.x && a.y == b.y; + } + }; +} + +void example_custom_key(Arena& arena) { + HashMap visits = {}; + auto init_r = visits.init(arena, 64); + BASE_ASSERT(init_r.is_ok()); + + Point p{10, 20}; + auto upsert_r = visits.upsert(arena, p, 1); + BASE_ASSERT(upsert_r.is_ok()); + + auto find_r = visits.find_ptr(Point{10, 20}); + BASE_ASSERT(find_r.is_ok()); + BASE_ASSERT(find_r.value != nullptr); +} +``` + +For set-style usage, the same specialization works unchanged with `HashSet`. + +### Example: reserve when you know approximate size + +If the caller can estimate the number of inserts ahead of time, reserving early reduces rehash frequency. + +```cpp +import core_types; +import core_memory; +import core_hash_map; + +using namespace base; + +void example_reserve(Arena& arena, u64 expected_items) { + HashMap table = {}; + + auto reserve_r = table.reserve(arena, expected_items * 2); + BASE_ASSERT(reserve_r.is_ok()); +} +``` + +The exact requested number does not need to be a power of two. The implementation normalizes it internally. + +### Example: clear without releasing storage + +`clear()` resets occupancy but does not return memory to the arena. + +```cpp +import core_types; +import core_memory; +import core_hash_map; + +using namespace base; + +void example_clear(Arena& arena) { + HashSet set = {}; + auto init_r = set.init(arena, 32); + BASE_ASSERT(init_r.is_ok()); + + auto ins_r = set.upsert(arena, 7); + BASE_ASSERT(ins_r.is_ok()); + + set.clear(); + BASE_ASSERT(set.empty()); +} +``` + +That is useful when the table object is reused repeatedly inside a larger arena lifetime. + +### Example: slot-based traversal (map and set) + +The container now exposes a minimal, index-based traversal surface: + +- `first_live_slot()` +- `next_live_slot(slot)` +- `npos()` +- `is_end_slot(slot)` +- `key_at(slot)` +- `value_at(slot)` (map only) + +This keeps iteration explicit and close to the underlying table representation. + +Map traversal: + +```cpp +for (u64 slot = map.first_live_slot(); + !map.is_end_slot(slot); + slot = map.next_live_slot(slot)) { + const K* key = map.key_at(slot); + const V* value = map.value_at(slot); + // use *key, *value +} +``` + +Set traversal: + +```cpp +for (u64 slot = set.first_live_slot(); + !set.is_end_slot(slot); + slot = set.next_live_slot(slot)) { + const K* key = set.key_at(slot); + // use *key +} +``` + +This style avoids heavyweight iterator abstractions while still preventing call sites from depending directly on raw control-byte checks. + +## What the Current Design Does Not Yet Do + +The table is intentionally focused. + +It does not yet implement: + +- erase; +- tombstones; +- SIMD-friendly metadata groups; +- heterogeneous lookup; + +That is a good thing for now. The implementation is still small enough to understand directly, and the current decisions are visible rather than buried under layers of generalization. + +## How This Relates to Other Modern Hash Tables + +This table is not trying to be a clone of SwissTable, Folly F14, or other industrial hash tables. + +Those designs push harder on: + +- packed metadata fingerprints; +- group probing; +- SIMD-accelerated negative lookup filtering; +- deeper engineering for deletion and iterator stability. + +The current project takes a simpler step first: + +- Robin Hood open addressing; +- cached full hashes; +- arena-friendly growth; +- a direct set specialization. + +That keeps the implementation teachable while still achieving most of the big structural wins over naive chaining or naive linear probing. + +## Suggested References + +These are useful references if you want to go deeper on the ideas behind this implementation. + +### Papers and classic references + +1. Pedro Celis, Per-Ake Larson, and J. Ian Munro, “Robin Hood Hashing.” +- The foundational reference for the technique used here. + +2. Donald Knuth, *The Art of Computer Programming*, Volume 3: *Sorting and Searching*. +- Still one of the best references for open addressing, probe behavior, and hash-table tradeoffs. + +3. Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein, *Introduction to Algorithms*. +- Good general background on hashing and table load-factor behavior. + +### Blog posts and engineering writeups + +1. Emmanuel Goossaert, “Robin Hood hashing.” +- A very approachable explanation of the insertion and search invariants. + +2. Sebastian Sylvan, writing on Robin Hood hashing and cache-friendly tables. +- Helpful for intuition about variance reduction and real-world behavior. + +3. Abseil's SwissTable design notes and related talks. +- Useful as a contrast case for where metadata-heavy high-performance tables go next. + +4. Malte Skarupke's blog posts on fast hash tables. +- Good practical perspective on engineering tradeoffs in modern C++ hash containers. + +### Videos and talks + +1. Conference talks on SwissTable and Abseil containers. +- Useful for understanding modern metadata-driven probing. + +2. Talks on data-oriented design and cache-aware container layout. +- Helpful for understanding why this project prefers contiguous open-addressed storage. + +## Final Takeaway + +This project's hash map is built around a simple idea: + +- keep the data contiguous, +- keep ownership arena-friendly, +- use Robin Hood hashing to stabilize probe lengths, +- cache hashes so string keys are not punished during probing, +- and get the set variant almost for free through specialization. + +That combination is small enough to study, strong enough to be useful, and flexible enough to improve later if benchmarks justify more advanced metadata techniques. \ No newline at end of file diff --git a/src/base/core_hash.cppm b/src/base/core_hash.cppm new file mode 100644 index 0000000..8a51d53 --- /dev/null +++ b/src/base/core_hash.cppm @@ -0,0 +1,102 @@ +module; + +export module core_hash; + +import core_types; +import core_string; + +using namespace base; + +export namespace base::hash { + + namespace detail { + // 64-bit finalizer mix (splitmix style) for stable, high-quality integer diffusion + constexpr u64 mix64(u64 x) { + x ^= (x >> 30); + x *= 0xbf58476d1ce4e5b9ULL; + x ^= (x >> 27); + x *= 0x94d049bb133111ebULL; + x ^= (x >> 31); + return x; + } + } + + template + struct HashTraits; // unsupported keys fail at compile time + + // Unsigned integer key traits + + template<> + struct HashTraits { + static constexpr u64 hash(const u8 key) { return detail::mix64((u64)key); } + static constexpr bool eq(const u8 a, const u8 b) { return a == b; } + }; + + template<> + struct HashTraits { + static constexpr u64 hash(const u16 key) { return detail::mix64((u64)key); } + static constexpr bool eq(const u16 a, const u16 b) { return a == b; } + }; + + template <> + struct HashTraits { + static constexpr u64 hash(const u32 key) { return detail::mix64((u64)key); } + static constexpr bool eq(const u32 a, const u32 b) { return a == b; } + }; + + template <> + struct HashTraits { + static constexpr u64 hash(const u64 key) { return detail::mix64(key); } + static constexpr bool eq(const u64 a, const u64 b) { return a == b; } + }; + + // Signed integer key traits + + template <> + struct HashTraits { + static constexpr u64 hash(const i8 key) { return detail::mix64((u64)(i64)key); } + static constexpr bool eq(const i8 a, const i8 b) { return a == b; } + }; + + template <> + struct HashTraits { + static constexpr u64 hash(const i16 key) { return detail::mix64((u64)(i64)key); } + static constexpr bool eq(const i16 a, const i16 b) { return a == b; } + }; + + template <> + struct HashTraits { + static constexpr u64 hash(const i32 key) { return detail::mix64((u64)(i64)key); } + static constexpr bool eq(const i32 a, const i32 b) { return a == b; } + }; + + template <> + struct HashTraits { + static constexpr u64 hash(const i64 key) { return detail::mix64((u64)key); } + static constexpr bool eq(const i64 a, const i64 b) { return a == b; } + }; + + // Str8 key traits, using existing hash & equality behavior + template<> + struct HashTraits { + static constexpr u64 hash(const Str8& key) { + return key.hash_fnv1a(); + } + + static constexpr bool eq(const Str8& a, const Str8& b) { + return a.match(b); + } + }; + + // Generic trait-dispatch helpers used by HashMap core + template + constexpr u64 hash_of(const K& key) { + return HashTraits::hash(key); + } + + template + constexpr bool key_eq(const K& a, const K& b) { + return HashTraits::eq(a, b); + } + +} // namespace base::hash \ No newline at end of file diff --git a/src/base/core_hash_map.cppm b/src/base/core_hash_map.cppm new file mode 100644 index 0000000..b06d9da --- /dev/null +++ b/src/base/core_hash_map.cppm @@ -0,0 +1,1005 @@ +module; + +#include +#include "core_debug.h" + +/** + * @file core_hash_map.cppm + * @brief Arena-backed Robin Hood hash map and hash set primitives. + */ + +/** + * @module core_hash_map + * @brief Open-addressed hash containers for map and set workloads. + */ +export module core_hash_map; + +import core_types; +import core_memory; +import core_hash; +import core_result; + +using namespace base; + +/** + * @namespace base::hashmap + * @brief Hash container primitives built on arena allocation. + */ +export namespace base::hashmap { + + /** + * @brief Error categories returned by HashMap operations. + */ + enum struct HashMapE : u8 { + /** @brief No error. */ + None = 0, + /** @brief Arena allocation failed while reserving or rehashing. */ + OutOfMemory, + /** @brief Probe walk exhausted the full table unexpectedly. */ + ProbeSequenceExhausted + }; + + /** + * @brief Result alias used by HashMap and HashSet APIs. + * @tparam T Success payload type. + */ + template + using HashMapR = Result; + + namespace detail { + /** @brief Control-byte marker for an empty slot. */ + constexpr u8 CTRL_EMPTY = 0; + /** @brief Control-byte marker for an occupied slot. */ + constexpr u8 CTRL_FULL = 1; + + /** @brief Load-factor numerator (4/5 == 80%). */ + constexpr u64 LOAD_NUM = 4; // 80% + /** @brief Load-factor denominator (4/5 == 80%). */ + constexpr u64 LOAD_DEN = 5; + + /** @brief Smallest table capacity the implementation will allocate. */ + constexpr u64 MIN_CAPACITY = 8; + + /** @brief Tests whether x is a non-zero power of two. */ + constexpr bool is_pow2_nonzero(u64 x) { + return x != 0 && ((x & (x - 1)) == 0); + } + + /** @brief Rounds x up to the next power of two. */ + constexpr u64 next_pow2(u64 x) { + if (x <= 1) return 1; + x -= 1; + x |= (x >> 1); + x |= (x >> 2); + x |= (x >> 4); + x |= (x >> 8); + x |= (x >> 16); + x |= (x >> 32); + return x + 1; + } + + /** @brief Normalizes a caller request to a supported table capacity. */ + constexpr u64 normalize_capacity(u64 requested_capacity) { + u64 capped = (requested_capacity < MIN_CAPACITY) ? MIN_CAPACITY : requested_capacity; + return next_pow2(capped); + } + + /** @brief Returns the maximum live-entry count allowed before growth. */ + constexpr u64 max_count_for_capacity(u64 capacity) { + return (capacity * LOAD_NUM) / LOAD_DEN; + } + + /** @brief Computes Robin Hood probe distance from a key's home slot. */ + constexpr u64 probe_distance(u64 slot, u64 home, u64 capacity) { + return (slot + capacity - home) & (capacity - 1); + } + + template + /** @brief Lightweight swap helper for trivially movable values. */ + inline void swap_values(T& a, T& b) { + T tmp = a; + a = b; + b = tmp; + } + + template + /** + * @brief Inserts or overwrites one key-value entry in raw map storage. + * @return Ok(true) when a new key is inserted, Ok(false) when an existing + * key is overwritten, or Err(HashMapE) on failure. + */ + inline HashMapR robin_hood_upsert_raw_map( + K* keys, + V* values, + u64* hashes, + u8* ctrl, + u64 capacity, + u64& io_count, + const K& key, + const V& value + ) { + BASE_ASSERT(keys != nullptr); + BASE_ASSERT(values != nullptr); + BASE_ASSERT(hashes != nullptr); + BASE_ASSERT(ctrl != nullptr); + BASE_ASSERT(is_pow2_nonzero(capacity)); + + const u64 mask = capacity - 1; + K cur_key = key; + V cur_value = value; + u64 cur_hash = hash_of(cur_key); + u64 idx = cur_hash & mask; + u64 dist = 0; + + for (u64 steps = 0; steps < capacity; ++steps) { + if (ctrl[idx] == CTRL_EMPTY) { + ctrl[idx] = CTRL_FULL; + keys[idx] = cur_key; + values[idx] = cur_value; + hashes[idx] = cur_hash; + io_count += 1; + return HashMapR::ok(true); + } + + if (hashes[idx] == cur_hash && key_eq(keys[idx], cur_key)) { + values[idx] = cur_value; + return HashMapR::ok(false); + } + + u64 resident_home = hashes[idx] & mask; + u64 resident_dist = probe_distance(idx, resident_home, capacity); + + if (resident_dist < dist) { + swap_values(keys[idx], cur_key); + swap_values(values[idx], cur_value); + swap_values(hashes[idx], cur_hash); + dist = resident_dist; + } + + idx = (idx + 1) & mask; + dist += 1; + } + + return HashMapR::err(HashMapE::ProbeSequenceExhausted); + } + + template + /** + * @brief Inserts one key into raw set storage if it is not already present. + * @return Ok(true) when a new key is inserted, Ok(false) when the key + * already exists, or Err(HashMapE) on failure. + */ + inline HashMapR robin_hood_upsert_raw_set( + K* keys, + u64* hashes, + u8* ctrl, + u64 capacity, + u64& io_count, + const K& key + ) { + BASE_ASSERT(keys != nullptr); + BASE_ASSERT(hashes != nullptr); + BASE_ASSERT(ctrl != nullptr); + BASE_ASSERT(is_pow2_nonzero(capacity)); + + const u64 mask = capacity - 1; + K cur_key = key; + u64 cur_hash = hash_of(cur_key); + u64 idx = cur_hash & mask; + u64 dist = 0; + + for (u64 steps = 0; steps < capacity; ++steps) { + if (ctrl[idx] == CTRL_EMPTY) { + ctrl[idx] = CTRL_FULL; + keys[idx] = cur_key; + hashes[idx] = cur_hash; + io_count += 1; + return HashMapR::ok(true); + } + + if (hashes[idx] == cur_hash && key_eq(keys[idx], cur_key)) { + return HashMapR::ok(false); + } + + u64 resident_home = hashes[idx] & mask; + u64 resident_dist = probe_distance(idx, resident_home, capacity); + + if (resident_dist < dist) { + swap_values(keys[idx], cur_key); + swap_values(hashes[idx], cur_hash); + dist = resident_dist; + } + + idx = (idx + 1) & mask; + dist += 1; + } + + return HashMapR::err(HashMapE::ProbeSequenceExhausted); + } + + template + /** + * @brief Finds a mutable value pointer inside raw map storage. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on probe failure. + */ + inline HashMapR robin_hood_find_ptr_raw_map( + K* keys, + V* values, + const u64* hashes, + const u8* ctrl, + u64 capacity, + const K& key + ) { + if (capacity == 0) { + return HashMapR::ok(nullptr); + } + + BASE_ASSERT(keys != nullptr); + BASE_ASSERT(values != nullptr); + BASE_ASSERT(hashes != nullptr); + BASE_ASSERT(ctrl != nullptr); + BASE_ASSERT(is_pow2_nonzero(capacity)); + + const u64 mask = capacity - 1; + u64 key_hash = hash_of(key); + u64 idx = key_hash & mask; + u64 dist = 0; + + for (u64 steps = 0; steps < capacity; ++steps) { + if (ctrl[idx] == CTRL_EMPTY) { + return HashMapR::ok(nullptr); + } + + u64 resident_home = hashes[idx] & mask; + u64 resident_dist = probe_distance(idx, resident_home, capacity); + + if (resident_dist < dist) { + return HashMapR::ok(nullptr); + } + + if (hashes[idx] == key_hash && key_eq(keys[idx], key)) { + return HashMapR::ok(&values[idx]); + } + + idx = (idx + 1) & mask; + dist += 1; + } + + return HashMapR::err(HashMapE::ProbeSequenceExhausted); + } + + template + /** + * @brief Finds a const value pointer inside raw map storage. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on probe failure. + */ + inline HashMapR robin_hood_find_ptr_raw_map_const( + const K* keys, + const V* values, + const u64* hashes, + const u8* ctrl, + u64 capacity, + const K& key + ) { + if (capacity == 0) { + return HashMapR::ok(nullptr); + } + + BASE_ASSERT(keys != nullptr); + BASE_ASSERT(values != nullptr); + BASE_ASSERT(hashes != nullptr); + BASE_ASSERT(ctrl != nullptr); + BASE_ASSERT(is_pow2_nonzero(capacity)); + + const u64 mask = capacity - 1; + u64 key_hash = hash_of(key); + u64 idx = key_hash & mask; + u64 dist = 0; + + for (u64 steps = 0; steps < capacity; ++steps) { + if (ctrl[idx] == CTRL_EMPTY) { + return HashMapR::ok(nullptr); + } + + u64 resident_home = hashes[idx] & mask; + u64 resident_dist = probe_distance(idx, resident_home, capacity); + + if (resident_dist < dist) { + return HashMapR::ok(nullptr); + } + + if (hashes[idx] == key_hash && key_eq(keys[idx], key)) { + return HashMapR::ok(&values[idx]); + } + + idx = (idx + 1) & mask; + dist += 1; + } + + return HashMapR::err(HashMapE::ProbeSequenceExhausted); + } + + template + /** + * @brief Finds a mutable key pointer inside raw set storage. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on probe failure. + */ + inline HashMapR robin_hood_find_key_ptr_raw_set( + K* keys, + const u64* hashes, + const u8* ctrl, + u64 capacity, + const K& key + ) { + if (capacity == 0) { + return HashMapR::ok(nullptr); + } + + BASE_ASSERT(keys != nullptr); + BASE_ASSERT(hashes != nullptr); + BASE_ASSERT(ctrl != nullptr); + BASE_ASSERT(is_pow2_nonzero(capacity)); + + const u64 mask = capacity - 1; + u64 key_hash = hash_of(key); + u64 idx = key_hash & mask; + u64 dist = 0; + + for (u64 steps = 0; steps < capacity; ++steps) { + if (ctrl[idx] == CTRL_EMPTY) { + return HashMapR::ok(nullptr); + } + + u64 resident_home = hashes[idx] & mask; + u64 resident_dist = probe_distance(idx, resident_home, capacity); + + if (resident_dist < dist) { + return HashMapR::ok(nullptr); + } + + if (hashes[idx] == key_hash && key_eq(keys[idx], key)) { + return HashMapR::ok(&keys[idx]); + } + + idx = (idx + 1) & mask; + dist += 1; + } + + return HashMapR::err(HashMapE::ProbeSequenceExhausted); + } + + template + /** + * @brief Finds a const key pointer inside raw set storage. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on probe failure. + */ + inline HashMapR robin_hood_find_key_ptr_raw_set_const( + const K* keys, + const u64* hashes, + const u8* ctrl, + u64 capacity, + const K& key + ) { + if (capacity == 0) { + return HashMapR::ok(nullptr); + } + + BASE_ASSERT(keys != nullptr); + BASE_ASSERT(hashes != nullptr); + BASE_ASSERT(ctrl != nullptr); + BASE_ASSERT(is_pow2_nonzero(capacity)); + + const u64 mask = capacity - 1; + u64 key_hash = hash_of(key); + u64 idx = key_hash & mask; + u64 dist = 0; + + for (u64 steps = 0; steps < capacity; ++steps) { + if (ctrl[idx] == CTRL_EMPTY) { + return HashMapR::ok(nullptr); + } + + u64 resident_home = hashes[idx] & mask; + u64 resident_dist = probe_distance(idx, resident_home, capacity); + + if (resident_dist < dist) { + return HashMapR::ok(nullptr); + } + + if (hashes[idx] == key_hash && key_eq(keys[idx], key)) { + return HashMapR::ok(&keys[idx]); + } + + idx = (idx + 1) & mask; + dist += 1; + } + + return HashMapR::err(HashMapE::ProbeSequenceExhausted); + } + + template + /** + * @brief Grows or initializes raw map storage and reinserts live entries. + * @return Ok(true) when new storage is allocated, Ok(false) when no growth + * is needed, or Err(HashMapE) on failure. + */ + inline HashMapR reserve_with_rehash_map( + Arena& arena, + u64 requested_capacity, + u64& io_capacity, + u64& io_count, + K*& io_keys, + V*& io_values, + u64*& io_hashes, + u8*& io_ctrl + ) { + u64 target_capacity = normalize_capacity(requested_capacity); + if (io_capacity >= target_capacity) { + return HashMapR::ok(false); + } + + u64 snapshot = arena.offset; + + K* new_keys = arena.alloc_array(target_capacity); + V* new_values = arena.alloc_array(target_capacity); + u64* new_hashes = arena.alloc_array(target_capacity); + u8* new_ctrl = arena.alloc_array(target_capacity); + + if (!new_keys || !new_ctrl || !new_values || !new_hashes) { + arena.offset = snapshot; + return HashMapR::err(HashMapE::OutOfMemory); + } + + memset(new_ctrl, CTRL_EMPTY, static_cast(target_capacity)); + + u64 new_count = 0; + for (u64 i = 0; i < io_capacity; ++i) { + if (io_ctrl && io_ctrl[i] == CTRL_FULL) { + auto ins = robin_hood_upsert_raw_map( + new_keys, new_values, new_hashes, new_ctrl, target_capacity, new_count, io_keys[i], io_values[i] + ); + if (!ins.is_ok()) { + arena.offset = snapshot; + return HashMapR::err(ins.error); + } + } + } + + BASE_ASSERT(new_count == io_count); + + io_keys = new_keys; + io_values = new_values; + io_hashes = new_hashes; + io_ctrl = new_ctrl; + io_capacity = target_capacity; + io_count = new_count; + + return HashMapR::ok(true); + } + + template + /** + * @brief Grows or initializes raw set storage and reinserts live entries. + * @return Ok(true) when new storage is allocated, Ok(false) when no growth + * is needed, or Err(HashMapE) on failure. + */ + inline HashMapR reserve_with_rehash_set( + Arena& arena, + u64 requested_capacity, + u64& io_capacity, + u64& io_count, + K*& io_keys, + u64*& io_hashes, + u8*& io_ctrl + ) { + u64 target_capacity = normalize_capacity(requested_capacity); + + if (io_capacity >= target_capacity) { + return HashMapR::ok(false); + } + + u64 snapshot = arena.offset; + + K* new_keys = arena.alloc_array(target_capacity); + u64* new_hashes = arena.alloc_array(target_capacity); + u8* new_ctrl = arena.alloc_array(target_capacity); + + if (!new_keys || !new_ctrl || !new_hashes) { + arena.offset = snapshot; + return HashMapR::err(HashMapE::OutOfMemory); + } + + memset(new_ctrl, CTRL_EMPTY, static_cast(target_capacity)); + + u64 new_count = 0; + for (u64 i = 0; i < io_capacity; ++i) { + if (io_ctrl && io_ctrl[i] == CTRL_FULL) { + auto ins = robin_hood_upsert_raw_set( + new_keys, new_hashes, new_ctrl, target_capacity, new_count, io_keys[i] + ); + if (!ins.is_ok()) { + arena.offset = snapshot; + return HashMapR::err(ins.error); + } + } + } + + BASE_ASSERT(new_count == io_count); + + io_keys = new_keys; + io_hashes = new_hashes; + io_ctrl = new_ctrl; + io_capacity = target_capacity; + io_count = new_count; + + return HashMapR::ok(true); + } + + } // namespace detail + + template + /** + * @brief Arena-backed hash map using Robin Hood open addressing. + * @tparam K Key type. + * @tparam V Value type. + * @note Intended primarily for trivially copyable key/value workloads. + */ + struct HashMap { + /** @brief Number of allocated slots; always a power of two when non-zero. */ + u64 capacity; + /** @brief Number of live entries currently stored in the table. */ + u64 count; + /** @brief Key array indexed in lockstep with values, hashes, and ctrl. */ + K* keys; + /** @brief Value array indexed in lockstep with keys, hashes, and ctrl. */ + V* values; + /** @brief Cached full hashes for resident keys. */ + u64* hashes; + /** @brief Occupancy control bytes for each slot. */ + u8* ctrl; + + /** @brief Constructs an empty, uninitialized map handle. */ + constexpr HashMap() + : capacity(0), count(0), keys(nullptr), values(nullptr), hashes(nullptr), ctrl(nullptr) {} + + /** @brief Returns the minimum supported capacity for a live table. */ + static constexpr u64 min_capacity() { + return detail::MIN_CAPACITY; + } + + /** @brief Returns the largest allowed live-entry count before growth. */ + static constexpr u64 max_count_for_capacity(u64 cap) { + return detail::max_count_for_capacity(cap); + } + + /** @brief Reports whether backing storage has been initialized. */ + constexpr bool is_initialized() const { + return this->capacity != 0; + } + + /** @brief Reports whether the table currently stores no live entries. */ + constexpr bool empty() const { + return this->count == 0; + } + + /** @brief Sentinel slot value used to represent end-of-iteration. */ + static constexpr u64 npos() { + return max_u64; + } + + /** @brief Returns true when slot equals the end sentinel. */ + constexpr bool is_end_slot(u64 slot) const { + return slot == npos(); + } + + /** + * @brief Returns the first live slot index, or npos() if none exist. + */ + inline u64 first_live_slot() const { + if (this->capacity == 0 || this->ctrl == nullptr) { + return npos(); + } + for(u64 i = 0; i < this->capacity; ++i) { + if (this->ctrl[i] == detail::CTRL_FULL) { + return i; + } + } + return npos(); + } + + /** + * @brief Returns the next live slot after \\p slot, or npos() at end. + */ + inline u64 next_live_slot(u64 slot) const { + if (this->capacity == 0 || this->ctrl == nullptr || slot >= this->capacity) { + return npos(); + } + for (u64 i = slot + 1; i < this->capacity; ++i) { + if (this->ctrl[i] == detail::CTRL_FULL) { + return i; + } + } + return npos(); + } + + /** @brief Returns mutable key pointer for a live slot. */ + inline K* key_at(u64 slot) { + BASE_ASSERT(slot < this->capacity); + BASE_ASSERT(this->ctrl[slot] == detail::CTRL_FULL); + return &this->keys[slot]; + } + + /** @brief Returns const key pointer for a live slot. */ + inline const K* key_at(u64 slot) const { + BASE_ASSERT(slot < this->capacity); + BASE_ASSERT(this->ctrl[slot] == detail::CTRL_FULL); + return &this->keys[slot]; + } + + /** @brief Returns mutable value pointer for a live slot. */ + inline V* value_at(u64 slot) { + BASE_ASSERT(slot < this->capacity); + BASE_ASSERT(this->ctrl[slot] == detail::CTRL_FULL); + return &this->values[slot]; + } + + /** @brief Returns const value pointer for a live slot. */ + inline const V* value_at(u64 slot) const { + BASE_ASSERT(slot < this->capacity); + BASE_ASSERT(this->ctrl[slot] == detail::CTRL_FULL); + return &this->values[slot]; + } + + /** + * @brief Initializes storage with at least the requested capacity. + * @param arena Arena supplying table storage. + * @param initial_capacity Requested minimum slot count. + */ + inline HashMapR init(Arena& arena, u64 initial_capacity = detail::MIN_CAPACITY) { + return this->reserve(arena, initial_capacity); + } + + /** + * @brief Ensures the table has capacity for at least requested_capacity slots. + * @param arena Arena supplying table storage. + * @param requested_capacity Requested minimum slot count before normalization. + */ + inline HashMapR reserve(Arena& arena, u64 requested_capacity) { + return detail::reserve_with_rehash_map( + arena, requested_capacity, + this->capacity, + this->count, + this->keys, + this->values, + this->hashes, + this->ctrl + ); + } + + /** + * @brief Grows the table if one more insertion would exceed the load target. + * @param arena Arena supplying new storage if growth is needed. + */ + inline HashMapR maybe_grow_for_insert(Arena& arena) { + if (this->capacity == 0) { + return this->reserve(arena, detail::MIN_CAPACITY); + } + + u64 next_count = this->count + 1; + if (next_count <= detail::max_count_for_capacity(this->capacity)) { + return HashMapR::ok(false); + } + + return this->reserve(arena, this->capacity * 2); + } + + /** + * @brief Finds a mutable pointer to the value for key. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR find_ptr(const K& key) { + return detail::robin_hood_find_ptr_raw_map( + this->keys, this->values, this->hashes, this->ctrl, this->capacity, key + ); + } + + /** + * @brief Finds a const pointer to the value for key. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR find_ptr(const K& key) const { + return detail::robin_hood_find_ptr_raw_map_const( + this->keys, this->values, this->hashes, this->ctrl, this->capacity, key + ); + } + + /** + * @brief Tests whether the table contains key. + * @return Ok(true) when present, Ok(false) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR contains(const K& key) const { + auto f = this->find_ptr(key); + if (!f.is_ok()) { + return HashMapR::err(f.error); + } + return HashMapR::ok(f.value != nullptr); + } + + /** + * @brief Inserts or overwrites one key-value pair. + * @return Ok(true) when a new key is inserted, Ok(false) when an existing + * key is updated, or Err(HashMapE) on failure. + */ + inline HashMapR upsert(Arena& arena, const K& key, const V& value) { + auto grow = this->maybe_grow_for_insert(arena); + if (!grow.is_ok()) { + return HashMapR::err(grow.error); + } + return detail::robin_hood_upsert_raw_map( + this->keys, this->values, this->hashes, this->ctrl, this->capacity, this->count, key, value + ); + } + + /** @brief Marks every slot empty without releasing arena storage. */ + inline void clear() { + if (this->ctrl && this->capacity > 0) { + memset(this->ctrl, detail::CTRL_EMPTY, static_cast(this->capacity)); + } + this->count = 0; + } + + /** @brief Returns the slot mask used for bitmask indexing. */ + constexpr u64 bucket_mask() const { + BASE_ASSERT(detail::is_pow2_nonzero(this->capacity)); + return this->capacity - 1; + } + }; // struct HashMap + + template + /** + * @brief Hash-set specialization storing keys only. + * @tparam K Key type. + */ + struct HashMap { + /** @brief Number of allocated slots; always a power of two when non-zero. */ + u64 capacity; + /** @brief Number of live keys currently stored in the table. */ + u64 count; + /** @brief Key array indexed in lockstep with hashes and ctrl. */ + K* keys; + /** @brief Cached full hashes for resident keys. */ + u64* hashes; + /** @brief Occupancy control bytes for each slot. */ + u8* ctrl; + + /** @brief Constructs an empty, uninitialized set handle. */ + constexpr HashMap() + : capacity(0), count(0), keys(nullptr), hashes(nullptr), ctrl(nullptr) {} + + static constexpr u64 min_capacity() { + return detail::MIN_CAPACITY; + } + + static constexpr u64 max_count_for_capacity(u64 cap) { + return detail::max_count_for_capacity(cap); + } + + /** @brief Reports whether backing storage has been initialized. */ + constexpr bool is_initialized() const { + return this->capacity != 0; + } + + /** @brief Reports whether the table currently stores no live entries. */ + constexpr bool empty() const { + return this->count == 0; + } + + /** @brief Sentinel slot value used to represent end-of-iteration. */ + static constexpr u64 npos() { + return max_u64; + } + + /** @brief Returns true when slot equals the end sentinel. */ + constexpr bool is_end_slot(u64 slot) const { + return slot == npos(); + } + + /** + * @brief Returns the first live slot index, or npos() if none exist. + */ + inline u64 first_live_slot() const { + if (this->capacity == 0 || this->ctrl == nullptr) { + return npos(); + } + + for (u64 i = 0; i < this->capacity; ++i) { + if (this->ctrl[i] == detail::CTRL_FULL) { + return i; + } + } + + return npos(); + } + + /** + * @brief Returns the next live slot after \\p slot, or npos() at end. + */ + inline u64 next_live_slot(u64 slot) const { + if (this->capacity == 0 || this->ctrl == nullptr) { + return npos(); + } + + if (slot >= this->capacity) { + return npos(); + } + + for (u64 i = slot + 1; i < this->capacity; ++i) { + if (this->ctrl[i] == detail::CTRL_FULL) { + return i; + } + } + + return npos(); + } + + /** @brief Returns mutable key pointer for a live slot. */ + inline K* key_at(u64 slot) { + BASE_ASSERT(slot < this->capacity); + BASE_ASSERT(this->ctrl[slot] == detail::CTRL_FULL); + return &this->keys[slot]; + } + + /** @brief Returns const key pointer for a live slot. */ + inline const K* key_at(u64 slot) const { + BASE_ASSERT(slot < this->capacity); + BASE_ASSERT(this->ctrl[slot] == detail::CTRL_FULL); + return &this->keys[slot]; + } + + /** + * @brief Initializes storage with at least the requested capacity. + * @param arena Arena supplying table storage. + * @param initial_capacity Requested minimum slot count. + */ + inline HashMapR init(Arena& arena, u64 initial_capacity = detail::MIN_CAPACITY) { + return this->reserve(arena, initial_capacity); + } + + /** + * @brief Ensures the set has capacity for at least requested_capacity slots. + * @param arena Arena supplying table storage. + * @param requested_capacity Requested minimum slot count before normalization. + */ + inline HashMapR reserve(Arena& arena, u64 requested_capacity) { + return detail::reserve_with_rehash_set( + arena, + requested_capacity, + this->capacity, + this->count, + this->keys, + this->hashes, + this->ctrl + ); + } + + /** + * @brief Grows the set if one more insertion would exceed the load target. + * @param arena Arena supplying new storage if growth is needed. + */ + inline HashMapR maybe_grow_for_insert(Arena& arena) { + if (this->capacity == 0) { + return this->reserve(arena, detail::MIN_CAPACITY); + } + + u64 next_count = this->count + 1; + if (next_count <= detail::max_count_for_capacity(this->capacity)) { + return HashMapR::ok(false); + } + + return this->reserve(arena, this->capacity * 2); + } + + /** + * @brief Finds a mutable pointer to the resident key. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR find_key_ptr(const K& key) { + return detail::robin_hood_find_key_ptr_raw_set( + this->keys, this->hashes, this->ctrl, this->capacity, key + ); + } + + /** + * @brief Finds a const pointer to the resident key. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR find_key_ptr(const K& key) const { + return detail::robin_hood_find_key_ptr_raw_set_const( + this->keys, this->hashes, this->ctrl, this->capacity, key + ); + } + + /** + * @brief Finds a mutable pointer to the resident key. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR find_ptr(const K& key) { + return this->find_key_ptr(key); + } + + /** + * @brief Finds a const pointer to the resident key. + * @return Ok(pointer) when found, Ok(nullptr) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR find_ptr(const K& key) const { + return this->find_key_ptr(key); + } + + /** + * @brief Tests whether the set contains key. + * @return Ok(true) when present, Ok(false) when absent, or Err(HashMapE) + * on failure. + */ + inline HashMapR contains(const K& key) const { + auto f = this->find_ptr(key); + if (!f.is_ok()) { + return HashMapR::err(f.error); + } + return HashMapR::ok(f.value != nullptr); + } + + /** + * @brief Inserts key if absent. + * @return Ok(true) when a new key is inserted, Ok(false) when the key is + * already present, or Err(HashMapE) on failure. + */ + inline HashMapR upsert(Arena& arena, const K& key) { + auto grow = this->maybe_grow_for_insert(arena); + if (!grow.is_ok()) { + return HashMapR::err(grow.error); + } + + return detail::robin_hood_upsert_raw_set( + this->keys, this->hashes, this->ctrl, this->capacity, this->count, key + ); + } + + /** @brief Marks every slot empty without releasing arena storage. */ + inline void clear() { + if (this->ctrl && this->capacity > 0) { + memset(this->ctrl, detail::CTRL_EMPTY, static_cast(this->capacity)); + } + this->count = 0; + } + + /** @brief Returns the slot mask used for bitmask indexing. */ + constexpr u64 bucket_mask() const { + BASE_ASSERT(detail::is_pow2_nonzero(this->capacity)); + return this->capacity - 1; + } + + }; // struct HashMap + + /** @brief Convenience alias for key-only set usage. */ + template + using HashSet = HashMap; + +} // namespace base::hashmap + +export namespace base { + using hashmap::HashMap; + using hashmap::HashMapE; + using hashmap::HashMapR; + using hashmap::HashSet; +} \ No newline at end of file diff --git a/src/base/core_string.cppm b/src/base/core_string.cppm index 3efeb1a..1a921b7 100644 --- a/src/base/core_string.cppm +++ b/src/base/core_string.cppm @@ -126,7 +126,7 @@ export namespace base::str { * @param other Comparison target string view. * @return True if lengths and bytes are equal. */ - constexpr bool match(Str8 other) { + constexpr bool match(Str8 other) const { if (this->len != other.len) return false; for (u64 i = 0; i < this->len; i++) { if (this->str[i] != other.str[i]) return false; @@ -139,7 +139,7 @@ export namespace base::str { * @return 64-bit hash value. * @note Hash is stable for identical byte sequences. */ - constexpr u64 hash_fnv1a() { + constexpr u64 hash_fnv1a() const { u64 hash = 14695981039346656037ULL; for (u64 i = 0; i < this->len; ++i) { hash ^= this->str[i];