Skip to content

Commit f2926e9

Browse files
Merge pull request #169 from marius-bughiu/feat/issue-63-robin-hood-dictionary
feat(collections): add RobinHoodDictionary with Robin Hood probing (#63)
2 parents 01ee398 + 1943c7d commit f2926e9

28 files changed

Lines changed: 3304 additions & 12 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ All notable changes to Celerity are documented here. This project follows [Keep
66

77
### Added
88

9+
- `RobinHoodDictionary<TKey, TValue, THasher>` in `Celerity.Collections` — a drop-in peer of `CelerityDictionary` that resolves collisions with **Robin Hood** open addressing instead of plain linear probing, delivering the 1.2.0 Robin Hood probing experiment ([#63](https://github.com/marius-bughiu/Celerity/issues/63)). Every occupied slot stores its probe sequence length (PSL — distance from the ideal slot) in a parallel `int[]`; on insert an incoming key that has travelled further than the resident displaces it ("robs from the rich") and the evicted entry is re-inserted further along, which bounds probe-length variance so worst-case lookups stay close to the average on clustered / adversarial key distributions. The stored PSL also lets a *negative* lookup terminate early — once the probe distance exceeds the resident slot's PSL the key cannot be present. Deletion is Robin Hood backward-shift (pull each follower back one slot, decrementing its PSL, until an empty or already-home slot). The public surface is byte-for-byte identical to `CelerityDictionary`: indexer get/set, `ContainsKey`, `ContainsValue`, `TryGetValue`, `Add`, `TryAdd`, `Remove` (both `bool Remove(key)` and `bool Remove(key, out TValue?)`), `Clear`, `Count`, allocation-free struct `Keys` / `Values` views and `Enumerator` (with `_version` mutation detection), `IReadOnlyDictionary<TKey, TValue?>`, and both constructors (`(capacity, loadFactor)` and `(IEnumerable<KeyValuePair<TKey, TValue>>, capacity, loadFactor)` with the same power-of-two sizing, load-factor-headroom bulk sizing, validation, and out-of-band `default(TKey)` handling). The insert path computes the key hash once and threads it through both the existence probe and the displacement insertion, so a new-key `Add` / `TryAdd` / indexer-set costs exactly one `Hash()` call (displaced residents are never re-hashed). The cost versus `CelerityDictionary` is the per-slot PSL `int` (more allocation) and a little extra insert work for the swaps; on uniform keys with a good hasher it is a wash or a slight loss, so it is documented as the pick for the clustered / adversarial case rather than a default. AOT-safe; hot-path lookup is allocation-free.
10+
- `RobinHoodDictionaryTests`, `RobinHoodDictionaryCollisionTests`, and `RobinHoodDictionaryEnumerationTests` — dedicated suites mirroring the `CelerityDictionary*` files. The functional tests cover insert/retrieve/overwrite, `ContainsKey`, `Remove`, resize, the out-of-band `0` / `Guid.Empty` default key surviving resize, `TryGetValue` hit/miss, `Clear` (including the already-empty no-op), and `Add` / `TryAdd` duplicate semantics; the collision suite adds full-collision insert/overwrite/remove/resize chains, the Robin Hood **displacement** path (a longer-travelled key robbing a richer resident while every key stays reachable), early-terminating negative lookups on clustered keys, `null`-string-key round-trips, and a **differential fuzz** (`RandomizedOps_ShouldMatchBclDictionary`, 5000 random Set/Remove/Lookup ops over a deliberately tiny key universe across four seeds) reconciled against a `Dictionary<int, int>` oracle to stress the swap / backward-shift machinery; the enumeration suite mirrors the full `Keys` / `Values` / `Enumerator` surface including default-key yield, mid-enumeration mutation detection, and the boxed generic / non-generic `IEnumerable` paths. A `RobinHoodDictionary` arm was also added to the differential testing layer: a CsCheck model property test (`CollectionModelPropertyTests.RobinHoodDictionary_ShouldMatch_BclDictionary`) and a `Celerity.Fuzz` target (`RobinHoodDictionary`, registered in `Differential.All`), both reconciled against a `Dictionary<int, int>` oracle.
11+
- Cross-collection shared tests extended to cover `RobinHoodDictionary` alongside `CelerityDictionary` in all thirteen shared files: `AddAndTryAddTests`, `ConstructorValidationTests`, `IEnumerableConstructorTests`, `IEnumerableConstructorNullPriorityTests`, `ReadOnlyDictionaryInterfaceTests`, `RemoveOutValueTests`, `ContainsValueTests`, `LoadFactorBoundaryTests`, `IndexerReturnTypeTests`, `IndexerOverwriteResizeTests` (overwrite at threshold does not resize), `TryAddDuplicateResizeTests` (duplicate `TryAdd` at threshold keeps an active enumerator valid; a new-key `TryAdd` invalidates it), `TryAddProbeCountTests` (a new-key / duplicate `TryAdd` does exactly one `Hash()` call — the single-hash insert path), and `BulkConstructorNoResizeTests` (a bulk construct from a known-`Count` source costs exactly N hashes with no resize).
12+
- `RobinHoodDictionaryBenchmark` in `Celerity.Benchmarks`, mirroring `CelerityDictionaryBenchmark` (Insert / Lookup / Remove against a `Dictionary<int, int>` baseline at 1k / 100k items, with the per-`Remove` `[IterationSetup]` pattern), and registered in `Program.cs`'s core `--ci` `benchmarkTypes` so the joined CI report and the gh-pages dashboard include it.
13+
- Benchmark dashboard wiring for `RobinHoodDictionary`: a "What ships in the box" ship card in `web/index.html`, and a `COLLECTIONS` entry (key / title / vs / ops) in both `web/dev/bench/index.html` and `web/dev/bench/detail.html` so the auto-published `data.js` is surfaced rather than ignored.
14+
- Documentation for `RobinHoodDictionary`: a full section in `docs/api/collections.md` (what Robin Hood probing does, when to pick it over `CelerityDictionary`, constructors, default-key handling, runnable usage example) and README updates — the Collections list, the "Choosing a collection" decision table, and a "Quick start" subsection.
915
- `SmallDictionary<TKey, TValue>` in `Celerity.Collections` — a dictionary tuned for the very-small (`n <= ~16`) case, where a linear scan over a flat backing array beats a probe-based hash table (the shape compilers, IL emitters, AST attribute bags, and per-request maps hit constantly). The 1.2.0 small-collection ([#61](https://github.com/marius-bughiu/Celerity/issues/61)). It stores entries in insertion-dense parallel `TKey?[]` / `TValue?[]` arrays and answers every query with a linear scan using `EqualityComparer<TKey>.Default`, so there is **no hasher** (no `THasher` type parameter): nothing is hashed, which means there is no empty-slot sentinel and therefore **no out-of-band default-key slot** — a `0` / `null` / `Guid.Empty` key is stored inline like any other, a deliberate simplification over the hash-table dictionaries. The trade-off is that lookups / `Add` / `TryAdd` / `ContainsKey` / `Remove` are `O(n)` rather than `O(1)`, so the type is for small key sets and is documented as degrading for large ones (it does not auto-promote to a hash table; it grows its arrays and keeps scanning). `Remove` swaps the last entry into the vacated slot (an `O(1)` move once the key is found), so enumeration order is unspecified; a pure indexer overwrite never grows the arrays. Public API mirrors the other Celerity dictionaries: indexer get/set (get returns the non-nullable `TValue` and throws `KeyNotFoundException` on miss), `ContainsKey`, `ContainsValue`, `TryGetValue`, `Add`, `TryAdd`, `Remove` (both `bool Remove(key)` and `bool Remove(key, out TValue?)`), `Clear`, `Count`, allocation-free struct `Keys` / `Values` views and `Enumerator` (with `_version` mutation detection), `IReadOnlyDictionary<TKey, TValue?>`, and an `IEnumerable<KeyValuePair<TKey, TValue>>` constructor (duplicate keys throw `ArgumentException`; a `null` source throws `ArgumentNullException`, checked before the capacity validation). The constructor takes a `capacity` (used verbatim, not rounded to a power of two, since there is no probe mask; `0` defers allocation) and — unlike the hash-table dictionaries — has **no `loadFactor`** parameter. AOT-safe (no reflection); hot-path lookup is allocation-free.
1016
- `SmallDictionaryTests` and `SmallDictionaryEnumerationTests` — dedicated suites mirroring `IntDictionaryTests` / `IntDictionaryEnumerationTests`, adapted for a hasher-less type: indexer insert/retrieve/overwrite (and overwrite-at-capacity not growing), `Remove` from first / middle / last slot via the swap-with-last path, grow-on-capacity-exceeded, the inline `0` / `null` default key exercised as an ordinary entry, `TryGetValue` hit/miss, `Clear` (including the already-empty no-op), remove-then-reinsert fidelity, zero-capacity deferred allocation, and the full enumeration surface (yield-once, reflect removal/clear, survive growth, mid-enumeration mutation/remove/clear detection on `MoveNext` and `Reset`, `Keys` / `Values` views and counts, the boxed generic and non-generic `IEnumerable` paths, and `Reset` reuse). A dedicated `*CollisionTests` file is intentionally **not** added: a linear-scan dictionary has no hashing and therefore no collisions to test.
1117
- A `SmallDictionary` parity arm added to the differential testing layer: a CsCheck model property test (`CollectionModelPropertyTests.SmallDictionary_ShouldMatch_BclDictionary`, against a `Dictionary<int, int>` oracle over a random Set/Remove/TryAdd/Clear op stream) and a `Celerity.Fuzz` target (`SmallDictionary`, registered in `Differential.All`) that fuzzes the same op stream against the BCL oracle and cross-checks count, per-key lookups, and duplicate-free enumeration.

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Celerity is a .NET library that provides specialized high-performance collection
66
## Collections
77

88
- `CelerityDictionary<TKey, TValue, THasher>` — generic dictionary with a struct hasher constraint.
9+
- `RobinHoodDictionary<TKey, TValue, THasher>``CelerityDictionary`'s peer using Robin Hood open addressing: bounds probe-length variance so worst-case lookups stay close to average on clustered / adversarial keys (at the cost of a per-slot probe-distance `int`).
910
- `FrozenCelerityDictionary<TValue>` / `FrozenCelerityDictionary<TValue, THasher>` — build-once, read-many `string`-keyed dictionary that searches for a perfect (collision-free) hash so lookups are single-probe. Defaults to `StringFnV1AHasher`.
1011
- `CelerityMultiMap<TKey, TValue, THasher>` — one-to-many map: each key groups multiple values (`Add` appends rather than overwrites). Implements `ILookup<TKey, TValue?>`.
1112
- `SmallDictionary<TKey, TValue>` — flat-array, linear-scan dictionary tuned for the very-small (`n <= ~16`) case. No hasher: it never hashes, so a `0` / `null` / default key is stored inline rather than out-of-band.
@@ -72,6 +73,23 @@ byKey[DateOnly.FromDateTime(DateTime.UtcNow)] = "today";
7273

7374
The hasher is a `struct` and is supplied as a generic constraint, so the JIT devirtualizes and inlines the `Hash()` call on the probe path.
7475

76+
### `RobinHoodDictionary` — bounded probe variance for clustered keys
77+
78+
When keys bunch up (weak or identity hashers, attacker-influenced keys, naturally clustered IDs), linear probing grows long runs and worst-case lookups degrade. `RobinHoodDictionary` is a drop-in peer of `CelerityDictionary` — same API, same hashers — that uses Robin Hood open addressing to keep probe-length variance low, so tail-latency lookups stay close to the average. It also stops a *negative* lookup early using its probe-distance invariant.
79+
80+
```csharp
81+
using Celerity.Collections;
82+
using Celerity.Hashing;
83+
84+
var dict = new RobinHoodDictionary<int, string, Int32WangNaiveHasher>();
85+
dict[42] = "hello";
86+
87+
if (dict.TryGetValue(42, out var val))
88+
Console.WriteLine(val); // "hello"
89+
```
90+
91+
The trade-off is a per-slot probe-distance `int` of bookkeeping (so it allocates more than `CelerityDictionary`); on uniformly distributed keys with a good hasher, `CelerityDictionary` matches or beats it, so prefer Robin Hood specifically for the clustered / adversarial case.
92+
7593
### `FrozenCelerityDictionary` — build-once string lookups
7694

7795
When a `string`-keyed table is built once and then read many times (route tables, config maps, interned vocabularies), `FrozenCelerityDictionary<TValue>` searches at construction for a perfect (collision-free) hash so each lookup is a single hash, a single array index, and a single equality check.
@@ -189,6 +207,7 @@ Celerity ships specialised types because each one buys a different tradeoff. Use
189207
| Dictionary keyed by `int` | `IntDictionary<TValue>` | Avoids generic boxing / `EqualityComparer<int>` dispatch; defaults to `Int32WangNaiveHasher`. |
190208
| Dictionary keyed by `long` | `LongDictionary<TValue>` | 64-bit equivalent of `IntDictionary`; defaults to `Int64WangNaiveHasher`. |
191209
| Dictionary keyed by `Guid`, `string`, or any other type | `CelerityDictionary<TKey, TValue, THasher>` | Pick a struct hasher from `Celerity.Hashing` (e.g. `GuidHasher`, `StringFnV1AHasher`) so the JIT can inline `Hash()` on the probe path. |
210+
| Dictionary with **clustered / adversarial** keys where worst-case lookup latency matters | `RobinHoodDictionary<TKey, TValue, THasher>` | Same API as `CelerityDictionary`, but Robin Hood probing bounds probe-length variance so tail-latency lookups don't degrade on bunched keys. Costs a per-slot probe-distance `int`; for uniform keys with a good hasher, prefer `CelerityDictionary`. |
192211
| Build-once, read-many lookup table keyed by `string` | `FrozenCelerityDictionary<TValue>` | Immutable; searches for a perfect (collision-free) hash at build time so lookups are single-probe. Tune the hasher via the `<TValue, THasher>` overload. |
193212
| One key maps to **many** values (one-to-many) | `CelerityMultiMap<TKey, TValue, THasher>` | `Add` appends to a per-key value group instead of overwriting; implements `ILookup<,>`. Pick the struct hasher for your key type, as with `CelerityDictionary`. |
194213
| Tiny dictionary (`n <= ~16`) that stays small | `SmallDictionary<TKey, TValue>` | Flat-array linear scan beats hashing at small `n` — no hash to compute, great cache locality, no hasher to pick. Degrades to `O(n)` for large key sets, so only when instances stay small. |

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Focus on raw performance and specialized collection types that serve more advanc
7777

7878
### Performance
7979

80-
- Robin Hood hashing experiment as alternative to linear probing. Tracked in [#63](https://github.com/marius-bughiu/Celerity/issues/63).
80+
- Robin Hood hashing experiment as alternative to linear probing. Status: `done` — shipped as a new collection type, `RobinHoodDictionary<TKey, TValue, THasher>`, a drop-in peer of `CelerityDictionary` that uses Robin Hood open addressing (per-slot probe sequence length, displace-the-richer-resident inserts, backward-shift-with-PSL-decrement deletes) to bound probe-length variance and keep worst-case lookups close to the average on clustered / adversarial keys; negative lookups terminate early via the PSL invariant. The default is unchanged — this is an additional opt-in type for the clustered case, not a replacement (the per-slot PSL `int` and extra insert work make it a wash or a slight loss on uniform keys). Tracked in [#63](https://github.com/marius-bughiu/Celerity/issues/63).
8181
- Performance optimizations across existing collections.
8282
- Native AOT support and trimming compatibility. Status: `done` — the library is marked `<IsAotCompatible>true</IsAotCompatible>` (trim + AOT analyzers run on every build), and a Native AOT publish smoke test runs the full collection / hasher surface as a native binary in CI. See [`docs/aot.md`](docs/aot.md). An AOT-vs-JIT benchmark comparison remains a follow-up. Tracked in [#32](https://github.com/marius-bughiu/Celerity/issues/32).
8383

docs/api/collections.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,67 @@ foreach (var value in dict.Values) { /* ... */ }
158158

159159
---
160160

161+
## RobinHoodDictionary&lt;TKey, TValue, THasher&gt;
162+
163+
A drop-in peer of `CelerityDictionary` that resolves collisions with **Robin Hood** open addressing instead of plain linear probing. The public surface — constructors, indexer, `ContainsKey` / `ContainsValue` / `TryGetValue` / `Add` / `TryAdd` / `Remove` / `Clear`, the struct `Enumerator` / `KeyCollection` / `ValueCollection`, and `IReadOnlyDictionary<TKey, TValue?>` — is identical to `CelerityDictionary`. Only the probing strategy differs.
164+
165+
```csharp
166+
public class RobinHoodDictionary<TKey, TValue, THasher>
167+
: IReadOnlyDictionary<TKey, TValue?>
168+
where THasher : struct, IHashProvider<TKey>
169+
```
170+
171+
### What Robin Hood probing does
172+
173+
For every occupied slot the table tracks how far the entry sits from its ideal (hash) slotits **probe sequence length** (PSL). On insert, an incoming key that has travelled further than the key already occupying a slot *displaces* it ("robs from the rich"): the resident is evicted and re-inserted further along. This keeps probe-length variance low, so the worst-case probe is much closer to the average than under linear probing. Two consequences matter to callers:
174+
175+
- **Bounded tail latency on clustered keys.** Where linear probing grows a single long run and degrades a lookup toward `O(n)`, Robin Hood spreads the cost evenly. The PSL invariant also lets a *negative* lookup stop earlyas soon as the probe distance exceeds the resident slot's PSL, the key cannot be present.
176+
- **A small, predictable overhead.** Each slot carries an extra `int` of PSL bookkeeping, so the dictionary allocates more than `CelerityDictionary`, and inserts do a little extra work for the displacement swaps. On uniform key distributions Robin Hood is typically a wash or a slight loss versus linear probing.
177+
178+
### When to choose it over `CelerityDictionary`
179+
180+
Reach for `RobinHoodDictionary` when your keys are **clustered or adversarial** (hash codes that bunch up, attacker-influenced keys, or a weak/identity hasher) and you care about **worst-case lookup latency**, not just the average. For uniformly distributed keys with a good hasher, stay on `CelerityDictionary` — it has the smaller footprint and matches or beats Robin Hood there. Both are single-threaded and make no iteration-order guarantee.
181+
182+
### Constructors
183+
184+
```csharp
185+
public RobinHoodDictionary(
186+
int capacity = 16,
187+
float loadFactor = 0.75f)
188+
189+
public RobinHoodDictionary(
190+
IEnumerable<KeyValuePair<TKey, TValue>> source,
191+
int capacity = 16,
192+
float loadFactor = 0.75f)
193+
```
194+
195+
Same semantics, sizing (including the `ICollection<T>` count-with-load-factor-headroom rule), validation, and exceptions as `CelerityDictionary`.
196+
197+
### Default-key handling
198+
199+
Identical to `CelerityDictionary`: `default(TKey)` (`null` / `0` / `Guid.Empty` / …) doubles as the empty-slot sentinel, so it is stored out-of-band via a `_hasDefaultKey` flag and a dedicated value slot. Transparent to callers.
200+
201+
### Usage example
202+
203+
```csharp
204+
using Celerity.Collections;
205+
using Celerity.Hashing;
206+
207+
// Clustered keys where linear probing would build long runs — Robin Hood
208+
// keeps every lookup's probe length close to the average.
209+
var dict = new RobinHoodDictionary<int, string, Int32WangNaiveHasher>();
210+
dict[42] = "hello";
211+
dict[0] = "zero is fine";
212+
213+
if (dict.TryGetValue(42, out var val))
214+
Console.WriteLine(val); // "hello"
215+
216+
foreach (var kvp in dict)
217+
Console.WriteLine($"{kvp.Key} -> {kvp.Value}");
218+
```
219+
220+
---
221+
161222
## IntDictionary&lt;TValue&gt;
162223

163224
A convenience subclass of `IntDictionary<TValue, Int32WangNaiveHasher>` for the common case of integer-keyed dictionaries.

src/Celerity.Benchmarks/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal class Program
1111
private static readonly Type[] CoreBenchmarks =
1212
{
1313
typeof(CelerityDictionaryBenchmark),
14+
typeof(RobinHoodDictionaryBenchmark),
1415
typeof(IntDictionaryBenchmark),
1516
typeof(LongDictionaryBenchmark),
1617
typeof(FrozenCelerityDictionaryBenchmark),

0 commit comments

Comments
 (0)