Skip to content

Commit b961162

Browse files
Merge pull request #322 from marius-bughiu/perf/issue-308-empty-slot-devirt
perf(collections): delete the per-probe virtual call on reference-type keys
2 parents 8e5376c + c5da62a commit b961162

23 files changed

Lines changed: 814 additions & 97 deletions

CHANGELOG.md

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

77
### Added
88

9+
- **`StringKeyProbeBenchmark`** — the tracked suite's first `string`-keyed dictionary and set rows (lookup hit, lookup miss, set `Contains`), shown as the **String-keyed probe** card on the benchmark dashboard. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308).
10+
- **`ReferenceKeyProbeTests`** — cross-collection coverage for vacant-slot detection on the open-addressed collections with reference-type keys. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308).
911
- **`BTreeDictionary<TKey, TValue, TComparer>` and `BTreeSet<T, TComparer>`** (with `BTreeDictionary<TKey, TValue>` / `BTreeSet<T>` aliases and the `DefaultComparer<T>` struct comparer) in `Celerity.Collections` — the library's first sorted map and set, and the B-tree the BCL lacks. Up to 31 keys per node keep a lookup `log₃₂(n)` node visits deep instead of chasing the `log₂(n)` pointers a red-black tree costs, and both add the ordered surface a hash table cannot answer: `Min` / `Max`, lower / upper bound, `EnumerateRange` in `O(log n + k)`, and in-order enumeration. They win on the interleaved insert + lookup + range-scan workload and on memory, and lose slightly on a delete-dominated one. Not thread-safe. Closes [#305](https://github.com/marius-bughiu/Celerity/issues/305).
1012

13+
### Changed
14+
15+
- Lookups on **reference-type keys** are faster across the open-addressed dictionaries and sets — around 10% on in-cache `string`-keyed lookups locally. Behaviour is identical, and value-type keys are unaffected. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308).
16+
- The README and performance guide now point `string`-keyed workloads at `HashCachingDictionary`: on 100k `string` keys `CelerityDictionary` measures behind the BCL `Dictionary`, and the hash-caching variant closes the gap. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308).
17+
1118
### Fixed
1219

1320
- **The coverage gate measured only one of the six shipped packages.** Coverlet's assembly filter is exact-match, so `Celerity.Hashing`, `Celerity.Primitives`, and the three showcase packages had been outside the gate since the 2.0.0 package split — any of them could have dropped to 0% with CI green. All six are now measured, the gaps that exposed are backfilled to **100% line and branch** coverage, and the floor is raised from 95%/90% to match. Closes [#314](https://github.com/marius-bughiu/Celerity/issues/314).

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,10 +547,10 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
547547
|---|---|---|
548548
| Dictionary keyed by `int` | `IntDictionary<TValue>` | Avoids generic boxing / `EqualityComparer<int>` dispatch; defaults to `Int32WangNaiveHasher`. |
549549
| Dictionary keyed by `long` | `LongDictionary<TValue>` | 64-bit equivalent of `IntDictionary`; defaults to `Int64WangNaiveHasher`. |
550-
| 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. |
550+
| 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. For **`string` keys**, try `HashCachingDictionary` (next rows but one) first: the BCL `Dictionary` stores a hash code per entry, and matching that is what closes the gap on reference-type keys — see the [performance guide](docs/performance.md#reference-type-keys-cache-the-hash). |
551551
| 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`. |
552552
| **Lookup-heavy** dictionary (large tables, many negative lookups) where SIMD pays off | `SwissDictionary<TKey, TValue, THasher>` | Same API as `CelerityDictionary`, but Swiss-table group probing tests 16 slots per `Vector128` compare and filters candidates by a 7-bit hash tag before any key comparison. Costs a one-byte control tag per slot; for small or write-dominated tables, `CelerityDictionary` is competitive. |
553-
| **Lookup-heavy** dictionary with **costly key equality** (long strings, large value-type keys) or large cache-cold tables | `HashCachingDictionary<TKey, TValue, THasher>` | Same API as `CelerityDictionary`, but a dense side array of 32-bit hash fingerprints lets probes scan metadata only and short-circuit the key comparison on a single integer compare. Costs four bytes of metadata per slot; complementary to `SwissDictionary` (scalar wide fingerprint vs SIMD one-byte tags). For small tables of cheap keys, `CelerityDictionary` is roughly a wash. |
553+
| **Lookup-heavy** dictionary with **costly key equality** (long strings, large value-type keys) or large cache-cold tables | `HashCachingDictionary<TKey, TValue, THasher>` | Same API as `CelerityDictionary`, but a dense side array of 32-bit hash fingerprints lets probes scan metadata only and short-circuit the key comparison on a single integer compare. Costs four bytes of metadata per slot; complementary to `SwissDictionary` (scalar wide fingerprint vs SIMD one-byte tags). For small tables of cheap keys, `CelerityDictionary` is roughly a wash. On 100k `string` keys it is the difference between losing to the BCL `Dictionary` and beating it on the negative-lookup path — see the [performance guide](docs/performance.md#reference-type-keys-cache-the-hash). |
554554
| **Short-lived** dictionary rebuilt frequently on a hot path where GC pressure matters | `PooledCelerityDictionary<TKey, TValue, THasher>` | Same API as `CelerityDictionary` plus `IDisposable`; rents its backing arrays from `ArrayPool<T>.Shared` and returns them on `Dispose`, so build/use/dispose cycles recycle buffers instead of allocating. Dispose it (a `using` scope); for long-lived dictionaries the pooling buys nothing, so prefer `CelerityDictionary`. |
555555
| 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. |
556556
| 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`. |

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10
213213

214214
- Fix `HyperLogLog`'s hash-entropy floor. `Hash64` widened a 32-bit `IHashProvider<T>` result, so the reachable hash space was 2^32 — while the type's own docs asserted a 64-bit space and skipped the classical large-range correction on that basis. The bias exceeded the advertised 0.81% standard error from ~1e8 distinct elements, in exactly the regime the type is sold for. Status: `done` — `IHashProvider64<T>` (`ulong Hash64(T key)`) ships as a standalone sibling interface in `Celerity.Hashing`, deliberately *not* deriving from `IHashProvider<T>` so the two contracts stay independent and a 64-bit hasher is never forced to publish a lossy 32-bit fold. Fourteen built-in hashers implement it — `Int64WangHasher`, `Int64Murmur3Hasher`, `UInt64WangHasher`, `UInt64Hasher`, `GuidHasher`, and the nine 64-bit `string` hashers — each of which already computed 64 bits internally and folded them away, so `Hash64` is the same mixer minus the narrowing. The 32-bit-only hashers (`Int32*` / `UInt32*`, the naive folds, `DefaultHasher<T>`) deliberately do not, since a key type narrower than 64 bits has no entropy to publish; a roster test pins that judgement. All five sketches route through it when the hasher provides it, via a compile-time type test the JIT folds away (so neither path allocates or branches) and with existing constructors and type parameters unchanged; on a 32-bit hasher `HyperLogLog` now applies the classical Flajolet large-range correction it previously skipped. `HashQualityEvaluator.Evaluate64` reports distribution over the 64-bit surface. Tracked in [#304](https://github.com/marius-bughiu/Celerity/issues/304).
215215
- Implement `IReadOnlySet<T>` on the mutable sets and `IDictionary<TKey, TValue>` on the dictionaries. The sets implement `ISet<T>` and the dictionaries `IReadOnlyDictionary<,>`, but `ISet<T>` does not derive from `IReadOnlySet<T>` — so an ordinary BCL-shaped API taking either interface is a compile error against a Celerity type today. This is the same Guiding Principle #3 gap the 2.2.0 set-algebra work closed, one level up. Status: `planned`.
216-
- Delete the per-probe virtual call. The probe loops test for an empty slot with `EqualityComparer<TKey>.Default.Equals(slot, default(TKey))`, which the JIT devirtualizes for value-type keys but not under `__Canon`-shared reference-type instantiations — one `callvirt` per probe iteration to perform what is a null check. Guiding Principle #2 exists to remove exactly this. Status: `planned`.
216+
- Delete the per-probe virtual call. The probe loops test for an empty slot with `EqualityComparer<TKey>.Default.Equals(slot, default(TKey))`, which the JIT devirtualizes for value-type keys but not under `__Canon`-shared reference-type instantiations — one `callvirt` per probe iteration to perform what is a null check. Guiding Principle #2 exists to remove exactly this. Status: `done` — the twelve open-addressed collections now route every vacant-slot test through an internal `EmptySlot.Is<T>` helper whose `typeof(T).IsValueType` guard the JIT folds, so a reference-type instantiation compiles to a plain null test and a value-type one keeps the existing intrinsic comparison unchanged. Behaviour is identical by construction and the whole existing suite passes untouched; `ReferenceKeyProbeTests` pins the substitution against a key type whose `Equals` claims equality with `null`, and the new `StringKeyProbeBenchmark` gives the dashboard its first reference-type-key rows. The follow-up `IEqualityProvider<T>` idea was **not** opened: a `HashCachingDictionary` control arm showed the residual reference-type-key deficit is dominated by re-hashing the key on every probe, not by the remaining equality dispatch — the actionable guidance is to use the hash-caching variants, now documented in [`docs/performance.md`](docs/performance.md#reference-type-keys-cache-the-hash). Tracked in [#308](https://github.com/marius-bughiu/Celerity/issues/308).
217217
- Span-keyed lookups on the string-keyed collections. .NET 9's `GetAlternateLookup<ReadOnlySpan<char>>` lets the BCL `Dictionary` probe with a span key and no allocation; Celerity's string-keyed types require a materialized `string`, so the BCL is now *ahead* on the axis this library has invested most in. Status: `planned`.
218218

219219
**The ordered / compressed integer-data lane.** Opened by the sorted-container hole — 38 collections and not one sorted map or set, with `Trie` the only ordered type, and that one string-keyed. The B-trees below close that half; the compressed-integer half is still open.

docs/api/collections.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,8 @@ The fingerprint of an occupied slot is the key's hash with its top bit forced se
385385

386386
Reach for `HashCachingDictionary` for **lookup-dominated** workloads, **expensive-equality keys** (long strings, large value-type keys), or large cache-cold tables where the metadata-only scan pays off, and where four bytes of metadata per slot is an acceptable cost. For small tables of cheap (e.g. `int`) keys, `CelerityDictionary` has the smaller footprint and is roughly a wash. It is complementary to `SwissDictionary`: both keep a metadata side array, but `HashCachingDictionary` is a scalar, wider-fingerprint design with backward-shift (tombstone-free) deletion, while `SwissDictionary` uses SIMD group probing over one-byte tags. Both are single-threaded and make no iteration-order guarantee.
387387

388+
`string` keys are the case worth calling out. The BCL `Dictionary<string, TValue>` already stores a hash code per entry, so `CelerityDictionary<string, …>` — which stores keys and nothing elsegives up a full ordinal string compare per probed slot and measures *behind* the BCL there. `HashCachingDictionary` restores that structure and the gap largely closes; on the negative-lookup path it turns into a win. See [reference-type keys: cache the hash](../performance.md#reference-type-keys-cache-the-hash) for the measured table.
389+
388390
### Constructors
389391

390392
```csharp

docs/performance.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ The single biggest win is using the type whose layout matches your key. The spec
2828

2929
See the full decision table with rationale in the [README](../README.md#choosing-a-collection). If your workload isn't on it, the BCL collection is usually the right starting point.
3030

31+
### Reference-type keys: cache the hash
32+
33+
Celerity's open-addressed tables store keys and nothing else, so a lookup **recomputes the hash of the probe key once and compares the key itself at every slot it visits**. For an `int` key both are near-free. For a `string` key the comparison is a full ordinal string compare, and the BCL `Dictionary<string, TValue>` sidesteps most of them by storing a 32-bit hash code per entry and rejecting a slot on an `int` compare first. On string keys that BCL structure wins:
34+
35+
| Workload (100k identifier-shaped `string` keys) | vs `Dictionary<string, int>` |
36+
|---|---|
37+
| `CelerityDictionary<string, int, StringXxHash3Hasher>` lookup (hit) | 1.33× slower |
38+
| `CelerityDictionary<string, int, StringXxHash3Hasher>` lookup (miss) | 1.60× slower |
39+
| `HashCachingDictionary<string, int, StringXxHash3Hasher>` lookup (hit) | 1.18× slower |
40+
| `HashCachingDictionary<string, int, StringXxHash3Hasher>` lookup (miss) | **0.94× — faster** |
41+
42+
`HashCachingDictionary<TKey, TValue, THasher>` / `HashCachingSet<T, THasher>` keep the same per-slot hash code the BCL does, which is what closes the gap — most of it on the miss path, where a probe walks the whole cluster to the first vacant slot. **If your keys are strings (or any reference type with a non-trivial `Equals`), reach for the hash-caching variants, not the plain ones.** The tradeoff is one extra `int` per slot.
43+
44+
These rows are tracked continuously as the **String-keyed probe** card on the [dashboard](https://marius-bughiu.github.io/Celerity/dev/bench/) (`StringKeyProbeBenchmark`); numbers above are from a local `--job medium` run and will differ on your hardware.
45+
3146
## 2. Use the struct fast paths, not the boxed interface
3247

3348
Every Celerity dictionary ships a **struct** `Enumerator` and struct `KeyCollection` / `ValueCollection` views. A plain `foreach` binds to the struct enumerator and allocates nothing:

src/Celerity.Benchmarks/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ internal class Program
5050
typeof(FenwickTreeBenchmark),
5151
typeof(BTreeDictionaryBenchmark),
5252
typeof(BTreeSetBenchmark),
53+
typeof(StringKeyProbeBenchmark),
5354
typeof(StringHasherBenchmark),
5455
typeof(IntegerHasherBenchmark),
5556
};
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using BenchmarkDotNet.Attributes;
2+
using BenchmarkDotNet.Configs;
3+
using Celerity.Collections;
4+
using Celerity.Hashing;
5+
6+
/// <summary>
7+
/// The reference-type-key probe path, which the rest of the tracked suite does not cover: every other
8+
/// dictionary / set benchmark here keys on <c>int</c> or <c>long</c>.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// A value-type key lets the JIT devirtualize and inline <c>EqualityComparer&lt;T&gt;.Default</c>, so the
13+
/// probe body is straight-line code. A reference-type key does not: the collection JITs as a
14+
/// <c>__Canon</c>-shared body and each comparison is a real interface dispatch. That makes the string-keyed
15+
/// tables the ones worth tracking for probe-path codegen work.
16+
/// </para>
17+
/// <para>
18+
/// <c>LookupMissing</c> is the probe-heavy arm on purpose: a hit stops at the matching slot, while a miss
19+
/// walks the whole cluster to the first vacant slot, so it pays the empty-slot test once per iteration.
20+
/// Both instances are built at the library default load factor — the shipping configuration, not a
21+
/// contrived one.
22+
/// </para>
23+
/// </remarks>
24+
[MemoryDiagnoser(false)]
25+
[CategoriesColumn]
26+
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
27+
public class StringKeyProbeBenchmark
28+
{
29+
private string[] keys = null!;
30+
private string[] missingKeys = null!;
31+
32+
private Dictionary<string, int> dictionary = null!;
33+
private CelerityDictionary<string, int, StringXxHash3Hasher> celerityDictionary = null!;
34+
35+
private HashSet<string> hashSet = null!;
36+
private CeleritySet<string, StringXxHash3Hasher> celeritySet = null!;
37+
38+
[Params(1000, 100_000)]
39+
public int ItemCount;
40+
41+
[GlobalSetup]
42+
public void Setup()
43+
{
44+
keys = new string[ItemCount];
45+
missingKeys = new string[ItemCount];
46+
for (int i = 0; i < ItemCount; i++)
47+
{
48+
// Identifier-shaped, guaranteed-distinct keys, matching the shape the
49+
// frozen-collection benchmarks use.
50+
keys[i] = "celerity/key/" + i + "/" + (i * 2654435761u);
51+
missingKeys[i] = "celerity/absent/" + i + "/" + (i * 2246822519u);
52+
}
53+
54+
dictionary = new Dictionary<string, int>(ItemCount);
55+
celerityDictionary = new CelerityDictionary<string, int, StringXxHash3Hasher>(ItemCount);
56+
hashSet = new HashSet<string>(ItemCount);
57+
celeritySet = new CeleritySet<string, StringXxHash3Hasher>(ItemCount);
58+
59+
for (int i = 0; i < ItemCount; i++)
60+
{
61+
dictionary[keys[i]] = i;
62+
celerityDictionary[keys[i]] = i;
63+
hashSet.Add(keys[i]);
64+
celeritySet.TryAdd(keys[i]);
65+
}
66+
}
67+
68+
// ── Lookup (every key present) ────────────────────────────────────────────
69+
70+
[Benchmark(Baseline = true)]
71+
[BenchmarkCategory("Lookup")]
72+
public int Dictionary_Lookup()
73+
{
74+
int acc = 0;
75+
foreach (var key in keys)
76+
acc += dictionary[key];
77+
return acc;
78+
}
79+
80+
[Benchmark]
81+
[BenchmarkCategory("Lookup")]
82+
public int CelerityDictionary_Lookup()
83+
{
84+
int acc = 0;
85+
foreach (var key in keys)
86+
acc += celerityDictionary[key];
87+
return acc;
88+
}
89+
90+
// ── LookupMissing (every probe walks to a vacant slot) ────────────────────
91+
92+
[Benchmark(Baseline = true)]
93+
[BenchmarkCategory("LookupMissing")]
94+
public int Dictionary_LookupMissing()
95+
{
96+
int acc = 0;
97+
foreach (var key in missingKeys)
98+
if (dictionary.TryGetValue(key, out int value))
99+
acc += value;
100+
return acc;
101+
}
102+
103+
[Benchmark]
104+
[BenchmarkCategory("LookupMissing")]
105+
public int CelerityDictionary_LookupMissing()
106+
{
107+
int acc = 0;
108+
foreach (var key in missingKeys)
109+
if (celerityDictionary.TryGetValue(key, out int value))
110+
acc += value;
111+
return acc;
112+
}
113+
114+
// ── Contains (the set counterpart, every element present) ─────────────────
115+
116+
[Benchmark(Baseline = true)]
117+
[BenchmarkCategory("Contains")]
118+
public int HashSet_Contains()
119+
{
120+
int hits = 0;
121+
foreach (var key in keys)
122+
if (hashSet.Contains(key))
123+
hits++;
124+
return hits;
125+
}
126+
127+
[Benchmark]
128+
[BenchmarkCategory("Contains")]
129+
public int CeleritySet_Contains()
130+
{
131+
int hits = 0;
132+
foreach (var key in keys)
133+
if (celeritySet.Contains(key))
134+
hits++;
135+
return hits;
136+
}
137+
}

0 commit comments

Comments
 (0)