From d336d4021a51e3be942b53f26d0b7ef72b62944d Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Sun, 26 Jul 2026 11:05:51 +0300 Subject: [PATCH 1/2] perf(collections): delete the per-probe virtual call on reference-type keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe loops tested for a vacant slot with `EqualityComparer.Default.Equals(slot, default(TKey))`. The JIT devirtualizes that for value-type keys, but not under __Canon-shared reference-type instantiations — so every string-keyed table paid a real interface call per probe iteration to ask whether a reference is null. Route every vacant-slot test through a new internal `EmptySlot.Is` helper whose `typeof(T).IsValueType` guard the JIT folds to a constant: reference-type instantiations compile to a plain null test, value-type ones keep the existing intrinsic comparison. The substitution is exact — the runtime's default comparers resolve a null right-hand side structurally, before consulting the key's own Equals. Also adds the suite's first string-keyed benchmark rows and documents, with numbers, that `CelerityDictionary` measures behind the BCL `Dictionary` on string keys and that `HashCachingDictionary` is the answer there. Closes #308 --- CHANGELOG.md | 7 + README.md | 4 +- ROADMAP.md | 2 +- docs/api/collections.md | 2 + docs/performance.md | 15 + src/Celerity.Benchmarks/Program.cs | 1 + .../StringKeyProbeBenchmark.cs | 137 +++++ .../Collections/ReferenceKeyProbeTests.cs | 520 ++++++++++++++++++ .../Collections/CelerityDictionary.cs | 25 +- src/Celerity/Collections/CelerityMultiMap.cs | 17 +- src/Celerity/Collections/CelerityMultiSet.cs | 17 +- src/Celerity/Collections/CeleritySet.cs | 19 +- src/Celerity/Collections/EmptySlot.cs | 51 ++ .../Collections/HashCachingDictionary.cs | 2 +- src/Celerity/Collections/HashCachingSet.cs | 2 +- .../Collections/PooledCelerityDictionary.cs | 20 +- src/Celerity/Collections/PooledCeleritySet.cs | 19 +- .../Collections/RobinHoodDictionary.cs | 19 +- src/Celerity/Collections/RobinHoodSet.cs | 16 +- src/Celerity/Collections/SwissDictionary.cs | 2 +- src/Celerity/Collections/SwissSet.cs | 2 +- web/dev/bench/detail.html | 5 +- web/dev/bench/index.html | 7 +- 23 files changed, 814 insertions(+), 97 deletions(-) create mode 100644 src/Celerity.Benchmarks/StringKeyProbeBenchmark.cs create mode 100644 src/Celerity.Tests/Collections/ReferenceKeyProbeTests.cs create mode 100644 src/Celerity/Collections/EmptySlot.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e7976b..85ddedcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,15 @@ All notable changes to Celerity are documented here. This project follows [Keep ### Added +- **`StringKeyProbeBenchmark`** — the tracked benchmark suite's first `string`-keyed dictionary and set rows (lookup hit, lookup miss, set `Contains`), registered in the core suite and rendered as the **String-keyed probe** card on the benchmark dashboard. Every other card keys on `int` / `long`. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). +- **`ReferenceKeyProbeTests`** — cross-collection coverage pinning vacant-slot detection on all twelve open-addressed collections against a key type whose `Equals` claims equality with `null`, plus the `null`-key round trip on each. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). - **`BTreeDictionary` and `BTreeSet`** (with `BTreeDictionary` / `BTreeSet` aliases and the `DefaultComparer` 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). +### Changed + +- Lookups on **reference-type keys** are faster across the open-addressed collections (`CelerityDictionary`, `RobinHoodDictionary`, `SwissDictionary`, `HashCachingDictionary`, `PooledCelerityDictionary`, `CelerityMultiMap` and their set counterparts): testing whether a slot is vacant no longer costs an interface call per probe iteration. Behaviour is identical and value-type keys are unaffected; locally, in-cache `string`-keyed lookups improved ~10%. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). +- The performance guide and the README now state plainly that `CelerityDictionary` measures **behind** the BCL `Dictionary` on `string` keys — the BCL stores a hash code per entry — and point `string`-keyed workloads at `HashCachingDictionary`, which closes the gap and wins outright on negative lookups. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). + ## [2.4.0] - 2026-07-26 ### Added diff --git a/README.md b/README.md index c882b3f8..5082543a 100644 --- a/README.md +++ b/README.md @@ -547,10 +547,10 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here, |---|---|---| | Dictionary keyed by `int` | `IntDictionary` | Avoids generic boxing / `EqualityComparer` dispatch; defaults to `Int32WangNaiveHasher`. | | Dictionary keyed by `long` | `LongDictionary` | 64-bit equivalent of `IntDictionary`; defaults to `Int64WangNaiveHasher`. | -| Dictionary keyed by `Guid`, `string`, or any other type | `CelerityDictionary` | Pick a struct hasher from `Celerity.Hashing` (e.g. `GuidHasher`, `StringFnV1AHasher`) so the JIT can inline `Hash()` on the probe path. | +| Dictionary keyed by `Guid`, `string`, or any other type | `CelerityDictionary` | 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). | | Dictionary with **clustered / adversarial** keys where worst-case lookup latency matters | `RobinHoodDictionary` | 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`. | | **Lookup-heavy** dictionary (large tables, many negative lookups) where SIMD pays off | `SwissDictionary` | 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. | -| **Lookup-heavy** dictionary with **costly key equality** (long strings, large value-type keys) or large cache-cold tables | `HashCachingDictionary` | 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. | +| **Lookup-heavy** dictionary with **costly key equality** (long strings, large value-type keys) or large cache-cold tables | `HashCachingDictionary` | 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). | | **Short-lived** dictionary rebuilt frequently on a hot path where GC pressure matters | `PooledCelerityDictionary` | Same API as `CelerityDictionary` plus `IDisposable`; rents its backing arrays from `ArrayPool.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`. | | Build-once, read-many lookup table keyed by `string` | `FrozenCelerityDictionary` | Immutable; searches for a perfect (collision-free) hash at build time so lookups are single-probe. Tune the hasher via the `` overload. | | One key maps to **many** values (one-to-many) | `CelerityMultiMap` | `Add` appends to a per-key value group instead of overwriting; implements `ILookup<,>`. Pick the struct hasher for your key type, as with `CelerityDictionary`. | diff --git a/ROADMAP.md b/ROADMAP.md index 1401c92d..118af84b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -213,7 +213,7 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10 - Fix `HyperLogLog`'s hash-entropy floor. `Hash64` widened a 32-bit `IHashProvider` 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` (`ulong Hash64(T key)`) ships as a standalone sibling interface in `Celerity.Hashing`, deliberately *not* deriving from `IHashProvider` 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`) 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). - Implement `IReadOnlySet` on the mutable sets and `IDictionary` on the dictionaries. The sets implement `ISet` and the dictionaries `IReadOnlyDictionary<,>`, but `ISet` does not derive from `IReadOnlySet` — 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`. -- Delete the per-probe virtual call. The probe loops test for an empty slot with `EqualityComparer.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`. +- Delete the per-probe virtual call. The probe loops test for an empty slot with `EqualityComparer.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` 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` 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). - Span-keyed lookups on the string-keyed collections. .NET 9's `GetAlternateLookup>` 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`. **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. diff --git a/docs/api/collections.md b/docs/api/collections.md index 240f9452..772be339 100644 --- a/docs/api/collections.md +++ b/docs/api/collections.md @@ -385,6 +385,8 @@ The fingerprint of an occupied slot is the key's hash with its top bit forced se 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. +`string` keys are the case worth calling out. The BCL `Dictionary` already stores a hash code per entry, so `CelerityDictionary` — which stores keys and nothing else — gives 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. + ### Constructors ```csharp diff --git a/docs/performance.md b/docs/performance.md index fd5f299f..d9a344e4 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -28,6 +28,21 @@ The single biggest win is using the type whose layout matches your key. The spec 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. +### Reference-type keys: cache the hash + +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` 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: + +| Workload (100k identifier-shaped `string` keys) | vs `Dictionary` | +|---|---| +| `CelerityDictionary` lookup (hit) | 1.33× slower | +| `CelerityDictionary` lookup (miss) | 1.60× slower | +| `HashCachingDictionary` lookup (hit) | 1.18× slower | +| `HashCachingDictionary` lookup (miss) | **0.94× — faster** | + +`HashCachingDictionary` / `HashCachingSet` 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. + +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. + ## 2. Use the struct fast paths, not the boxed interface Every Celerity dictionary ships a **struct** `Enumerator` and struct `KeyCollection` / `ValueCollection` views. A plain `foreach` binds to the struct enumerator and allocates nothing: diff --git a/src/Celerity.Benchmarks/Program.cs b/src/Celerity.Benchmarks/Program.cs index 2fc36ffd..72aa01c9 100644 --- a/src/Celerity.Benchmarks/Program.cs +++ b/src/Celerity.Benchmarks/Program.cs @@ -50,6 +50,7 @@ internal class Program typeof(FenwickTreeBenchmark), typeof(BTreeDictionaryBenchmark), typeof(BTreeSetBenchmark), + typeof(StringKeyProbeBenchmark), typeof(StringHasherBenchmark), typeof(IntegerHasherBenchmark), }; diff --git a/src/Celerity.Benchmarks/StringKeyProbeBenchmark.cs b/src/Celerity.Benchmarks/StringKeyProbeBenchmark.cs new file mode 100644 index 00000000..8c1e7159 --- /dev/null +++ b/src/Celerity.Benchmarks/StringKeyProbeBenchmark.cs @@ -0,0 +1,137 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using Celerity.Collections; +using Celerity.Hashing; + +/// +/// The reference-type-key probe path, which the rest of the tracked suite does not cover: every other +/// dictionary / set benchmark here keys on int or long. +/// +/// +/// +/// A value-type key lets the JIT devirtualize and inline EqualityComparer<T>.Default, so the +/// probe body is straight-line code. A reference-type key does not: the collection JITs as a +/// __Canon-shared body and each comparison is a real interface dispatch. That makes the string-keyed +/// tables the ones worth tracking for probe-path codegen work. +/// +/// +/// LookupMissing is the probe-heavy arm on purpose: a hit stops at the matching slot, while a miss +/// walks the whole cluster to the first vacant slot, so it pays the empty-slot test once per iteration. +/// Both instances are built at the library default load factor — the shipping configuration, not a +/// contrived one. +/// +/// +[MemoryDiagnoser(false)] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +public class StringKeyProbeBenchmark +{ + private string[] keys = null!; + private string[] missingKeys = null!; + + private Dictionary dictionary = null!; + private CelerityDictionary celerityDictionary = null!; + + private HashSet hashSet = null!; + private CeleritySet celeritySet = null!; + + [Params(1000, 100_000)] + public int ItemCount; + + [GlobalSetup] + public void Setup() + { + keys = new string[ItemCount]; + missingKeys = new string[ItemCount]; + for (int i = 0; i < ItemCount; i++) + { + // Identifier-shaped, guaranteed-distinct keys, matching the shape the + // frozen-collection benchmarks use. + keys[i] = "celerity/key/" + i + "/" + (i * 2654435761u); + missingKeys[i] = "celerity/absent/" + i + "/" + (i * 2246822519u); + } + + dictionary = new Dictionary(ItemCount); + celerityDictionary = new CelerityDictionary(ItemCount); + hashSet = new HashSet(ItemCount); + celeritySet = new CeleritySet(ItemCount); + + for (int i = 0; i < ItemCount; i++) + { + dictionary[keys[i]] = i; + celerityDictionary[keys[i]] = i; + hashSet.Add(keys[i]); + celeritySet.TryAdd(keys[i]); + } + } + + // ── Lookup (every key present) ──────────────────────────────────────────── + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Lookup")] + public int Dictionary_Lookup() + { + int acc = 0; + foreach (var key in keys) + acc += dictionary[key]; + return acc; + } + + [Benchmark] + [BenchmarkCategory("Lookup")] + public int CelerityDictionary_Lookup() + { + int acc = 0; + foreach (var key in keys) + acc += celerityDictionary[key]; + return acc; + } + + // ── LookupMissing (every probe walks to a vacant slot) ──────────────────── + + [Benchmark(Baseline = true)] + [BenchmarkCategory("LookupMissing")] + public int Dictionary_LookupMissing() + { + int acc = 0; + foreach (var key in missingKeys) + if (dictionary.TryGetValue(key, out int value)) + acc += value; + return acc; + } + + [Benchmark] + [BenchmarkCategory("LookupMissing")] + public int CelerityDictionary_LookupMissing() + { + int acc = 0; + foreach (var key in missingKeys) + if (celerityDictionary.TryGetValue(key, out int value)) + acc += value; + return acc; + } + + // ── Contains (the set counterpart, every element present) ───────────────── + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Contains")] + public int HashSet_Contains() + { + int hits = 0; + foreach (var key in keys) + if (hashSet.Contains(key)) + hits++; + return hits; + } + + [Benchmark] + [BenchmarkCategory("Contains")] + public int CeleritySet_Contains() + { + int hits = 0; + foreach (var key in keys) + if (celeritySet.Contains(key)) + hits++; + return hits; + } +} diff --git a/src/Celerity.Tests/Collections/ReferenceKeyProbeTests.cs b/src/Celerity.Tests/Collections/ReferenceKeyProbeTests.cs new file mode 100644 index 00000000..af52537f --- /dev/null +++ b/src/Celerity.Tests/Collections/ReferenceKeyProbeTests.cs @@ -0,0 +1,520 @@ +using Celerity.Collections; +using Celerity.Hashing; + +namespace Celerity.Tests.Collections; + +/// +/// Pins the empty-slot test on the open-addressed collections for reference-type keys. +/// +/// +/// +/// A vacant slot is default(TKey), which for a reference type is null. The probe loops +/// therefore answer "is this slot vacant?" with a plain null test rather than with +/// EqualityComparer<TKey>.Default.Equals(slot, default), so the check costs nothing under a +/// __Canon-shared instantiation (see EmptySlot). +/// +/// +/// That substitution is only sound because the runtime's default comparers answer a null +/// right-hand side structurally, before consulting the key's own Equals. +/// is the adversary for exactly that: its Equals claims equality with null. If the empty-slot +/// test ever routed through it, every occupied slot would read as vacant and these tests would fail — +/// lost entries, wrong counts, and lookups that miss keys that are present. +/// +/// +/// These are behaviour-pinning tests, not regression tests for a fixed bug: they pass both before and +/// after the codegen change, which is the point — they are what makes it safe to keep the null test. +/// +/// +public class ReferenceKeyProbeTests +{ + // Enough keys to force several resizes and long probe chains, so the empty-slot + // test is exercised on the insert, lookup, resize and backward-shift paths. + private const int KEY_COUNT = 200; + + /// + /// A key whose Equals deliberately claims equality with null — and therefore with the + /// vacant-slot sentinel. Hashing and non-null equality are ordinary identity-on-Id, so + /// the only thing this type can break is an empty-slot test that consults Equals. + /// + private sealed class NullGreedyKey : IEquatable + { + public NullGreedyKey(int id) => Id = id; + + public int Id { get; } + + public bool Equals(NullGreedyKey? other) => other is null || other.Id == Id; + + public override bool Equals(object? obj) => obj is null || (obj is NullGreedyKey other && other.Id == Id); + + public override int GetHashCode() => Id; + } + + private static NullGreedyKey[] Keys(int count = KEY_COUNT) + { + var keys = new NullGreedyKey[count]; + for (int i = 0; i < count; i++) + keys[i] = new NullGreedyKey(i + 1); + return keys; + } + + private static NullGreedyKey Missing => new NullGreedyKey(int.MaxValue); + + // ---------------- The contract the null test rests on ---------------- + + [Fact] + public void DefaultComparer_ShouldAnswerNullStructurally_EvenWhenEqualsClaimsEqualityWithNull() + { + // The adversary is live: the key's own Equals really does claim equality with null, + // on both the IEquatable and the object overload. + Assert.True(new NullGreedyKey(1).Equals(null)); + Assert.True(new NullGreedyKey(1).Equals((object?)null)); + + var key = new NullGreedyKey(1); + + // And yet the runtime's default comparer says false, because it resolves a null + // right-hand side before it ever consults the key. That is the invariant that makes + // `slot is null` an exact substitute for `comparer.Equals(slot, default(TKey))` — if it + // ever stopped holding, every probe loop in the library would be wrong and this fails. + Assert.False(EqualityComparer.Default.Equals(key, null)); + Assert.True(EqualityComparer.Default.Equals(null, null)); + } + + // ---------------- CelerityDictionary ---------------- + + [Fact] + public void CelerityDictionary_ShouldProbeCorrectly_WhenKeyEqualsClaimsEqualityWithNull() + { + var map = new CelerityDictionary>(); + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + map[key] = key.Id; + + Assert.Equal(keys.Length, map.Count); + foreach (var key in keys) + { + Assert.True(map.ContainsKey(key)); + Assert.True(map.TryGetValue(key, out int value)); + Assert.Equal(key.Id, value); + } + + Assert.False(map.ContainsKey(Missing)); + Assert.False(map.TryGetValue(Missing, out _)); + + // Backward-shift deletion walks the cluster with the same empty-slot test. + for (int i = 0; i < keys.Length; i += 2) + Assert.True(map.Remove(keys[i])); + + Assert.Equal(keys.Length / 2, map.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.True(map.ContainsKey(keys[i])); + } + + [Fact] + public void CelerityDictionary_ShouldStoreNullKeyOutOfBand_WhenKeyIsAReferenceType() + { + var map = new CelerityDictionary>(); + NullGreedyKey[] keys = Keys(16); + + foreach (var key in keys) + map[key] = key.Id; + + map[null!] = -1; + + Assert.Equal(keys.Length + 1, map.Count); + Assert.True(map.ContainsKey(null!)); + Assert.Equal(-1, map[null!]); + foreach (var key in keys) + Assert.Equal(key.Id, map[key]); + + Assert.True(map.Remove(null!)); + Assert.False(map.ContainsKey(null!)); + Assert.Equal(keys.Length, map.Count); + } + + // ---------------- RobinHoodDictionary ---------------- + + [Fact] + public void RobinHoodDictionary_ShouldProbeCorrectly_WhenKeyEqualsClaimsEqualityWithNull() + { + var map = new RobinHoodDictionary>(); + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + map[key] = key.Id; + + Assert.Equal(keys.Length, map.Count); + foreach (var key in keys) + Assert.Equal(key.Id, map[key]); + + Assert.False(map.ContainsKey(Missing)); + + for (int i = 0; i < keys.Length; i += 2) + Assert.True(map.Remove(keys[i])); + + Assert.Equal(keys.Length / 2, map.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.True(map.ContainsKey(keys[i])); + } + + [Fact] + public void RobinHoodDictionary_ShouldStoreNullKeyOutOfBand_WhenKeyIsAReferenceType() + { + var map = new RobinHoodDictionary>(); + foreach (var key in Keys(16)) + map[key] = key.Id; + + map[null!] = -1; + + Assert.True(map.ContainsKey(null!)); + Assert.Equal(-1, map[null!]); + Assert.True(map.Remove(null!)); + Assert.False(map.ContainsKey(null!)); + } + + // ---------------- SwissDictionary ---------------- + + [Fact] + public void SwissDictionary_ShouldProbeCorrectly_WhenKeyEqualsClaimsEqualityWithNull() + { + var map = new SwissDictionary>(); + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + map[key] = key.Id; + + Assert.Equal(keys.Length, map.Count); + foreach (var key in keys) + Assert.Equal(key.Id, map[key]); + + Assert.False(map.ContainsKey(Missing)); + + for (int i = 0; i < keys.Length; i += 2) + Assert.True(map.Remove(keys[i])); + + Assert.Equal(keys.Length / 2, map.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.True(map.ContainsKey(keys[i])); + } + + [Fact] + public void SwissDictionary_ShouldStoreNullKeyOutOfBand_WhenKeyIsAReferenceType() + { + var map = new SwissDictionary>(); + foreach (var key in Keys(16)) + map[key] = key.Id; + + map[null!] = -1; + + Assert.True(map.ContainsKey(null!)); + Assert.Equal(-1, map[null!]); + Assert.True(map.Remove(null!)); + Assert.False(map.ContainsKey(null!)); + } + + // ---------------- HashCachingDictionary ---------------- + + [Fact] + public void HashCachingDictionary_ShouldProbeCorrectly_WhenKeyEqualsClaimsEqualityWithNull() + { + var map = new HashCachingDictionary>(); + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + map[key] = key.Id; + + Assert.Equal(keys.Length, map.Count); + foreach (var key in keys) + Assert.Equal(key.Id, map[key]); + + Assert.False(map.ContainsKey(Missing)); + + for (int i = 0; i < keys.Length; i += 2) + Assert.True(map.Remove(keys[i])); + + Assert.Equal(keys.Length / 2, map.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.True(map.ContainsKey(keys[i])); + } + + [Fact] + public void HashCachingDictionary_ShouldStoreNullKeyOutOfBand_WhenKeyIsAReferenceType() + { + var map = new HashCachingDictionary>(); + foreach (var key in Keys(16)) + map[key] = key.Id; + + map[null!] = -1; + + Assert.True(map.ContainsKey(null!)); + Assert.Equal(-1, map[null!]); + Assert.True(map.Remove(null!)); + Assert.False(map.ContainsKey(null!)); + } + + // ---------------- PooledCelerityDictionary ---------------- + + [Fact] + public void PooledCelerityDictionary_ShouldProbeCorrectly_WhenKeyEqualsClaimsEqualityWithNull() + { + using var map = new PooledCelerityDictionary>(); + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + map[key] = key.Id; + + Assert.Equal(keys.Length, map.Count); + foreach (var key in keys) + Assert.Equal(key.Id, map[key]); + + Assert.False(map.ContainsKey(Missing)); + + for (int i = 0; i < keys.Length; i += 2) + Assert.True(map.Remove(keys[i])); + + Assert.Equal(keys.Length / 2, map.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.True(map.ContainsKey(keys[i])); + } + + [Fact] + public void PooledCelerityDictionary_ShouldStoreNullKeyOutOfBand_WhenKeyIsAReferenceType() + { + using var map = new PooledCelerityDictionary>(); + foreach (var key in Keys(16)) + map[key] = key.Id; + + map[null!] = -1; + + Assert.True(map.ContainsKey(null!)); + Assert.Equal(-1, map[null!]); + Assert.True(map.Remove(null!)); + Assert.False(map.ContainsKey(null!)); + } + + // ---------------- CelerityMultiMap ---------------- + + [Fact] + public void CelerityMultiMap_ShouldProbeCorrectly_WhenKeyEqualsClaimsEqualityWithNull() + { + var map = new CelerityMultiMap>(); + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + { + map.Add(key, key.Id); + map.Add(key, -key.Id); + } + + Assert.Equal(keys.Length, map.Count); + foreach (var key in keys) + { + Assert.True(map.ContainsKey(key)); + Assert.Equal(2, map.CountValues(key)); + Assert.True(map.Contains(key, key.Id)); + } + + Assert.False(map.ContainsKey(Missing)); + + for (int i = 0; i < keys.Length; i += 2) + Assert.True(map.RemoveAll(keys[i])); + + Assert.Equal(keys.Length / 2, map.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.True(map.ContainsKey(keys[i])); + } + + [Fact] + public void CelerityMultiMap_ShouldStoreNullKeyOutOfBand_WhenKeyIsAReferenceType() + { + var map = new CelerityMultiMap>(); + foreach (var key in Keys(16)) + map.Add(key, key.Id); + + map.Add(null!, -1); + + Assert.True(map.ContainsKey(null!)); + Assert.True(map.Contains(null!, -1)); + Assert.True(map.RemoveAll(null!)); + Assert.False(map.ContainsKey(null!)); + } + + // ---------------- CeleritySet ---------------- + + [Fact] + public void CeleritySet_ShouldProbeCorrectly_WhenElementEqualsClaimsEqualityWithNull() + { + var set = new CeleritySet>(); + AssertSetProbesCorrectly(set); + } + + [Fact] + public void CeleritySet_ShouldStoreNullElementOutOfBand_WhenElementIsAReferenceType() + { + var set = new CeleritySet>(); + AssertNullElementRoundTrips(set); + } + + // ---------------- RobinHoodSet ---------------- + + [Fact] + public void RobinHoodSet_ShouldProbeCorrectly_WhenElementEqualsClaimsEqualityWithNull() + { + var set = new RobinHoodSet>(); + AssertSetProbesCorrectly(set); + } + + [Fact] + public void RobinHoodSet_ShouldStoreNullElementOutOfBand_WhenElementIsAReferenceType() + { + var set = new RobinHoodSet>(); + AssertNullElementRoundTrips(set); + } + + // ---------------- SwissSet ---------------- + + [Fact] + public void SwissSet_ShouldProbeCorrectly_WhenElementEqualsClaimsEqualityWithNull() + { + var set = new SwissSet>(); + AssertSetProbesCorrectly(set); + } + + [Fact] + public void SwissSet_ShouldStoreNullElementOutOfBand_WhenElementIsAReferenceType() + { + var set = new SwissSet>(); + AssertNullElementRoundTrips(set); + } + + // ---------------- HashCachingSet ---------------- + + [Fact] + public void HashCachingSet_ShouldProbeCorrectly_WhenElementEqualsClaimsEqualityWithNull() + { + var set = new HashCachingSet>(); + AssertSetProbesCorrectly(set); + } + + [Fact] + public void HashCachingSet_ShouldStoreNullElementOutOfBand_WhenElementIsAReferenceType() + { + var set = new HashCachingSet>(); + AssertNullElementRoundTrips(set); + } + + // ---------------- PooledCeleritySet ---------------- + + [Fact] + public void PooledCeleritySet_ShouldProbeCorrectly_WhenElementEqualsClaimsEqualityWithNull() + { + using var set = new PooledCeleritySet>(); + AssertSetProbesCorrectly(set); + } + + [Fact] + public void PooledCeleritySet_ShouldStoreNullElementOutOfBand_WhenElementIsAReferenceType() + { + using var set = new PooledCeleritySet>(); + AssertNullElementRoundTrips(set); + } + + // ---------------- CelerityMultiSet ---------------- + + [Fact] + public void CelerityMultiSet_ShouldProbeCorrectly_WhenElementEqualsClaimsEqualityWithNull() + { + var set = new CelerityMultiSet>(); + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + set.Add(key, 2); + + Assert.Equal(keys.Length, set.Count); + Assert.Equal(keys.Length * 2, set.TotalCount); + foreach (var key in keys) + Assert.Equal(2, set.GetCount(key)); + + Assert.False(set.Contains(Missing)); + Assert.Equal(0, set.GetCount(Missing)); + + for (int i = 0; i < keys.Length; i += 2) + Assert.True(set.RemoveAll(keys[i])); + + Assert.Equal(keys.Length / 2, set.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.True(set.Contains(keys[i])); + } + + [Fact] + public void CelerityMultiSet_ShouldStoreNullElementOutOfBand_WhenElementIsAReferenceType() + { + var set = new CelerityMultiSet>(); + foreach (var key in Keys(16)) + set.Add(key); + + set.Add(null!, 3); + + Assert.True(set.Contains(null!)); + Assert.Equal(3, set.GetCount(null!)); + Assert.True(set.RemoveAll(null!)); + Assert.False(set.Contains(null!)); + } + + // ---------------- Value-type keys are unaffected ---------------- + + [Fact] + public void CelerityDictionary_ShouldKeepDefaultKeySemantics_WhenKeyIsAValueType() + { + // The empty-slot test only changes shape for reference-type keys; a value-type + // key still compares against default(TKey), and default(int) == 0 remains a + // legal key stored out-of-band. + var map = new CelerityDictionary(); + for (int i = 0; i < 64; i++) + map[i] = i; + + Assert.Equal(64, map.Count); + Assert.True(map.ContainsKey(0)); + Assert.Equal(0, map[0]); + Assert.True(map.Remove(0)); + Assert.False(map.ContainsKey(0)); + Assert.Equal(63, map.Count); + } + + // ---------------- Shared assertions ---------------- + + private static void AssertSetProbesCorrectly(ISet set) + { + NullGreedyKey[] keys = Keys(); + + foreach (var key in keys) + Assert.True(set.Add(key)); + + Assert.Equal(keys.Length, set.Count); + foreach (var key in keys) + Assert.Contains(key, set); + + Assert.DoesNotContain(Missing, set); + + for (int i = 0; i < keys.Length; i += 2) + Assert.True(set.Remove(keys[i])); + + Assert.Equal(keys.Length / 2, set.Count); + for (int i = 1; i < keys.Length; i += 2) + Assert.Contains(keys[i], set); + } + + private static void AssertNullElementRoundTrips(ISet set) + { + foreach (var key in Keys(16)) + Assert.True(set.Add(key)); + + Assert.True(set.Add(null!)); + + Assert.Equal(17, set.Count); + Assert.Contains(null!, set); + Assert.True(set.Remove(null!)); + Assert.DoesNotContain(null!, set); + Assert.Equal(16, set.Count); + } +} diff --git a/src/Celerity/Collections/CelerityDictionary.cs b/src/Celerity/Collections/CelerityDictionary.cs index c614c6d2..63689dd3 100644 --- a/src/Celerity/Collections/CelerityDictionary.cs +++ b/src/Celerity/Collections/CelerityDictionary.cs @@ -256,7 +256,6 @@ public bool ContainsValue(TValue? value) if (_hasDefaultKey && valueComparer.Equals(_defaultKeyValue, value)) return true; - var keyComparer = EqualityComparer.Default; TKey?[] keys = _keys; TValue?[] values = _values; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); @@ -264,7 +263,7 @@ public bool ContainsValue(TValue? value) int length = keys.Length; for (int i = 0; i < length; i++) { - if (!keyComparer.Equals(Unsafe.Add(ref keysRef, (nint)(uint)i), default(TKey)) && + if (!EmptySlot.Is(Unsafe.Add(ref keysRef, (nint)(uint)i)) && valueComparer.Equals(Unsafe.Add(ref valuesRef, (nint)(uint)i), value)) { return true; @@ -590,11 +589,10 @@ public bool MoveNext() int length = keys.Length; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); ref TValue? valuesRef = ref MemoryMarshal.GetArrayDataReference(values); - var comparer = EqualityComparer.Default; while (++_index < length) { TKey? key = Unsafe.Add(ref keysRef, (nint)(uint)_index); - if (!comparer.Equals(key, default(TKey))) + if (!EmptySlot.Is(key)) { _current = new KeyValuePair(key!, Unsafe.Add(ref valuesRef, (nint)(uint)_index)); return true; @@ -751,7 +749,7 @@ public struct Enumerator : IEnumerator IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); private static bool IsDefaultKey(TKey key) => - EqualityComparer.Default.Equals(key, default(TKey)); + EmptySlot.Is(key); // Returns the slot the caller should write into. // tells the caller whether the slot was previously empty (true → new entry, @@ -764,6 +762,11 @@ private static bool IsDefaultKey(TKey key) => // bound is structural: `mask = keys.Length - 1` and `index = ... & mask` // keep `index ∈ [0, keys.Length)` for every iteration. `(nint)(uint)index` // gives the JIT the additional hint that `index` is non-negative. + // + // The vacant-slot test goes through EmptySlot.Is rather than the comparer: for a + // reference-type TKey the comparer call is a real interface dispatch under __Canon + // sharing, and the helper folds it away to a null test. The second comparison — the + // actual key match — still needs the comparer. [MethodImpl(MethodImplOptions.AggressiveInlining)] private int ProbeForInsert(TKey key, out bool wasEmpty) { @@ -776,7 +779,7 @@ private int ProbeForInsert(TKey key, out bool wasEmpty) while (true) { TKey? slot = Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slot, default(TKey))) { wasEmpty = true; return index; } + if (EmptySlot.Is(slot)) { wasEmpty = true; return index; } if (comparer.Equals(slot, key)) { wasEmpty = false; return index; } index = (index + 1) & mask; } @@ -794,7 +797,7 @@ private int ProbeForKey(TKey key) while (true) { TKey? slot = Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slot, default(TKey))) return -1; + if (EmptySlot.Is(slot)) return -1; if (comparer.Equals(slot, key)) return index; index = (index + 1) & mask; } @@ -832,15 +835,14 @@ private void Resize(int newSize) ref TKey? newKeysRef = ref MemoryMarshal.GetArrayDataReference(newKeys); ref TValue? newValuesRef = ref MemoryMarshal.GetArrayDataReference(newValues); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldKeys.Length; i++) { TKey? key = Unsafe.Add(ref oldKeysRef, (nint)(uint)i); - if (comparer.Equals(key, default(TKey))) + if (EmptySlot.Is(key)) continue; int index = _hasher.Hash(key!) & mask; - while (!comparer.Equals(Unsafe.Add(ref newKeysRef, (nint)(uint)index), default(TKey))) + while (!EmptySlot.Is(Unsafe.Add(ref newKeysRef, (nint)(uint)index))) index = (index + 1) & mask; Unsafe.Add(ref newKeysRef, (nint)(uint)index) = key; @@ -867,7 +869,6 @@ private void BackwardShiftRemove(int startIndex) ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); ref TValue? valuesRef = ref MemoryMarshal.GetArrayDataReference(values); int mask = keys.Length - 1; - var comparer = EqualityComparer.Default; int i = startIndex; int j = i; @@ -875,7 +876,7 @@ private void BackwardShiftRemove(int startIndex) { j = (j + 1) & mask; TKey? candidateKey = Unsafe.Add(ref keysRef, (nint)(uint)j); - if (comparer.Equals(candidateKey, default(TKey))) + if (EmptySlot.Is(candidateKey)) break; int k = _hasher.Hash(candidateKey!) & mask; diff --git a/src/Celerity/Collections/CelerityMultiMap.cs b/src/Celerity/Collections/CelerityMultiMap.cs index 4156a767..81040633 100644 --- a/src/Celerity/Collections/CelerityMultiMap.cs +++ b/src/Celerity/Collections/CelerityMultiMap.cs @@ -536,7 +536,7 @@ public void TrimExcess(int capacity) // ---- internal helpers ---- private static bool IsDefaultKey(TKey key) => - EqualityComparer.Default.Equals(key, default(TKey)); + EmptySlot.Is(key); // Returns the existing or newly-created value group for key, updating _count // (and the default-key bookkeeping) when a new key is introduced, but NOT @@ -597,7 +597,7 @@ private int ProbeForInsert(TKey key, out bool wasEmpty) while (true) { TKey? slot = Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slot, default(TKey))) { wasEmpty = true; return index; } + if (EmptySlot.Is(slot)) { wasEmpty = true; return index; } if (comparer.Equals(slot, key)) { wasEmpty = false; return index; } index = (index + 1) & mask; } @@ -615,7 +615,7 @@ private int ProbeForKey(TKey key) while (true) { TKey? slot = Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slot, default(TKey))) return -1; + if (EmptySlot.Is(slot)) return -1; if (comparer.Equals(slot, key)) return index; index = (index + 1) & mask; } @@ -642,15 +642,14 @@ private void Resize(int newSize) ref TKey? oldKeysRef = ref MemoryMarshal.GetArrayDataReference(oldKeys); ref TKey? newKeysRef = ref MemoryMarshal.GetArrayDataReference(newKeys); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldKeys.Length; i++) { TKey? key = Unsafe.Add(ref oldKeysRef, (nint)(uint)i); - if (comparer.Equals(key, default(TKey))) + if (EmptySlot.Is(key)) continue; int index = _hasher.Hash(key!) & mask; - while (!comparer.Equals(Unsafe.Add(ref newKeysRef, (nint)(uint)index), default(TKey))) + while (!EmptySlot.Is(Unsafe.Add(ref newKeysRef, (nint)(uint)index))) index = (index + 1) & mask; Unsafe.Add(ref newKeysRef, (nint)(uint)index) = key; @@ -673,7 +672,6 @@ private void BackwardShiftRemove(int startIndex) List?[] groups = _groups; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); int mask = keys.Length - 1; - var comparer = EqualityComparer.Default; int i = startIndex; int j = i; @@ -681,7 +679,7 @@ private void BackwardShiftRemove(int startIndex) { j = (j + 1) & mask; TKey? candidateKey = Unsafe.Add(ref keysRef, (nint)(uint)j); - if (comparer.Equals(candidateKey, default(TKey))) + if (EmptySlot.Is(candidateKey)) break; int k = _hasher.Hash(candidateKey!) & mask; @@ -893,11 +891,10 @@ public bool MoveNext() List?[] groups = _map._groups; int length = keys.Length; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); - var comparer = EqualityComparer.Default; while (++_index < length) { TKey? key = Unsafe.Add(ref keysRef, (nint)(uint)_index); - if (!comparer.Equals(key, default(TKey))) + if (!EmptySlot.Is(key)) { _current = new Grouping(key!, groups[_index]); return true; diff --git a/src/Celerity/Collections/CelerityMultiSet.cs b/src/Celerity/Collections/CelerityMultiSet.cs index bac93a16..0a72bce2 100644 --- a/src/Celerity/Collections/CelerityMultiSet.cs +++ b/src/Celerity/Collections/CelerityMultiSet.cs @@ -559,7 +559,7 @@ IEnumerator> IEnumerable>.GetEnumerato // ---- internal helpers ---- private static bool IsDefaultKey(T element) => - EqualityComparer.Default.Equals(element, default(T)); + EmptySlot.Is(element); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int CheckedAdd(int current, int add) @@ -581,7 +581,7 @@ private int ProbeForInsert(T element, out bool wasEmpty) while (true) { T? slot = Unsafe.Add(ref elementsRef, (nint)(uint)index); - if (comparer.Equals(slot, default(T))) { wasEmpty = true; return index; } + if (EmptySlot.Is(slot)) { wasEmpty = true; return index; } if (comparer.Equals(slot, element)) { wasEmpty = false; return index; } index = (index + 1) & mask; } @@ -599,7 +599,7 @@ private int ProbeForKey(T element) while (true) { T? slot = Unsafe.Add(ref elementsRef, (nint)(uint)index); - if (comparer.Equals(slot, default(T))) return -1; + if (EmptySlot.Is(slot)) return -1; if (comparer.Equals(slot, element)) return index; index = (index + 1) & mask; } @@ -626,15 +626,14 @@ private void Resize(int newSize) ref T? oldElementsRef = ref MemoryMarshal.GetArrayDataReference(oldElements); ref T? newElementsRef = ref MemoryMarshal.GetArrayDataReference(newElements); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldElements.Length; i++) { T? element = Unsafe.Add(ref oldElementsRef, (nint)(uint)i); - if (comparer.Equals(element, default(T))) + if (EmptySlot.Is(element)) continue; int index = _hasher.Hash(element!) & mask; - while (!comparer.Equals(Unsafe.Add(ref newElementsRef, (nint)(uint)index), default(T))) + while (!EmptySlot.Is(Unsafe.Add(ref newElementsRef, (nint)(uint)index))) index = (index + 1) & mask; Unsafe.Add(ref newElementsRef, (nint)(uint)index) = element; @@ -657,7 +656,6 @@ private void BackwardShiftRemove(int startIndex) int[] counts = _counts; ref T? elementsRef = ref MemoryMarshal.GetArrayDataReference(elements); int mask = elements.Length - 1; - var comparer = EqualityComparer.Default; int i = startIndex; int j = i; @@ -665,7 +663,7 @@ private void BackwardShiftRemove(int startIndex) { j = (j + 1) & mask; T? candidate = Unsafe.Add(ref elementsRef, (nint)(uint)j); - if (comparer.Equals(candidate, default(T))) + if (EmptySlot.Is(candidate)) break; int k = _hasher.Hash(candidate!) & mask; @@ -750,11 +748,10 @@ public bool MoveNext() int[] counts = _set._counts; int length = elements.Length; ref T? elementsRef = ref MemoryMarshal.GetArrayDataReference(elements); - var comparer = EqualityComparer.Default; while (++_index < length) { T? element = Unsafe.Add(ref elementsRef, (nint)(uint)_index); - if (!comparer.Equals(element, default(T))) + if (!EmptySlot.Is(element)) { _current = new KeyValuePair(element!, counts[_index]); return true; diff --git a/src/Celerity/Collections/CeleritySet.cs b/src/Celerity/Collections/CeleritySet.cs index 48e6e9ff..87453f9f 100644 --- a/src/Celerity/Collections/CeleritySet.cs +++ b/src/Celerity/Collections/CeleritySet.cs @@ -196,7 +196,7 @@ public bool TryAdd(T item) while (true) { T? slot = Unsafe.Add(ref slotsRef, (nint)(uint)index); - if (comparer.Equals(slot, default(T))) break; + if (EmptySlot.Is(slot)) break; if (comparer.Equals(slot, item)) return false; index = (index + 1) & mask; } @@ -208,7 +208,7 @@ public bool TryAdd(T item) slotsRef = ref MemoryMarshal.GetArrayDataReference(slots); mask = slots.Length - 1; index = _hasher.Hash(item) & mask; - while (!comparer.Equals(Unsafe.Add(ref slotsRef, (nint)(uint)index), default(T))) + while (!EmptySlot.Is(Unsafe.Add(ref slotsRef, (nint)(uint)index))) index = (index + 1) & mask; } @@ -528,11 +528,10 @@ public bool MoveNext() T?[] slots = _set._slots; int length = slots.Length; ref T? slotsRef = ref MemoryMarshal.GetArrayDataReference(slots); - var comparer = EqualityComparer.Default; while (++_index < length) { T? slot = Unsafe.Add(ref slotsRef, (nint)(uint)_index); - if (!comparer.Equals(slot, default(T))) + if (!EmptySlot.Is(slot)) { _current = slot; return true; @@ -568,7 +567,7 @@ public void Dispose() { } } private static bool IsDefaultValue(T item) => - EqualityComparer.Default.Equals(item, default(T)); + EmptySlot.Is(item); [MethodImpl(MethodImplOptions.AggressiveInlining)] private int ProbeForItem(T item) @@ -582,7 +581,7 @@ private int ProbeForItem(T item) while (true) { T? slot = Unsafe.Add(ref slotsRef, (nint)(uint)index); - if (comparer.Equals(slot, default(T))) return -1; + if (EmptySlot.Is(slot)) return -1; if (comparer.Equals(slot, item)) return index; index = (index + 1) & mask; } @@ -617,15 +616,14 @@ private void Resize(int newSize) ref T? oldSlotsRef = ref MemoryMarshal.GetArrayDataReference(oldSlots); ref T? newSlotsRef = ref MemoryMarshal.GetArrayDataReference(newSlots); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldSlots.Length; i++) { T? item = Unsafe.Add(ref oldSlotsRef, (nint)(uint)i); - if (comparer.Equals(item, default(T))) + if (EmptySlot.Is(item)) continue; int index = _hasher.Hash(item!) & mask; - while (!comparer.Equals(Unsafe.Add(ref newSlotsRef, (nint)(uint)index), default(T))) + while (!EmptySlot.Is(Unsafe.Add(ref newSlotsRef, (nint)(uint)index))) index = (index + 1) & mask; Unsafe.Add(ref newSlotsRef, (nint)(uint)index) = item; @@ -648,7 +646,6 @@ private void BackwardShiftRemove(int startIndex) T?[] slots = _slots; ref T? slotsRef = ref MemoryMarshal.GetArrayDataReference(slots); int mask = slots.Length - 1; - var comparer = EqualityComparer.Default; int i = startIndex; int j = i; @@ -656,7 +653,7 @@ private void BackwardShiftRemove(int startIndex) { j = (j + 1) & mask; T? candidate = Unsafe.Add(ref slotsRef, (nint)(uint)j); - if (comparer.Equals(candidate, default(T))) + if (EmptySlot.Is(candidate)) break; int k = _hasher.Hash(candidate!) & mask; diff --git a/src/Celerity/Collections/EmptySlot.cs b/src/Celerity/Collections/EmptySlot.cs new file mode 100644 index 00000000..9c22d979 --- /dev/null +++ b/src/Celerity/Collections/EmptySlot.cs @@ -0,0 +1,51 @@ +using System.Runtime.CompilerServices; + +namespace Celerity.Collections; + +/// +/// The open-addressed collections mark a vacant slot by leaving it at default(T). This is the +/// single place that asks "is this slot vacant?", written so the question costs nothing for a +/// reference-type element. +/// +/// +/// +/// The naive spelling is EqualityComparer<T>.Default.Equals(slot, default(T)). For a +/// value-type T the JIT's intrinsic +/// devirtualizes and inlines that, so it is already free. For a reference type it is not: the +/// collection JITs as a __Canon-shared body and the call stays a real interface dispatch — one +/// virtual call per probe iteration, to ask whether a reference is null. +/// +/// +/// typeof(T).IsValueType is a JIT-time constant (__Canon only ever stands in for a +/// reference type), so exactly one arm below is compiled into each instantiation: value types keep the +/// intrinsic comparison, reference types get a plain null test. This mirrors Guiding Principle #2 — +/// the same reason the hashers are structs passed as generic constraints. +/// +/// +/// The substitution is exact, not an approximation. Every the runtime +/// supplies for a reference type answers a null right-hand side structurally, before it consults +/// the element's own Equals — so Equals(x, null) is true exactly when x is +/// null, even for an element type whose Equals claims equality with everything. +/// ReferenceKeyProbeTests pins that. +/// +/// +internal static class EmptySlot +{ + /// + /// Returns true when is default(T) — i.e. the slot is vacant, + /// or (on the out-of-band default-key paths) the element is the sentinel and cannot be stored + /// in the table. + /// + /// The stored element or key type. + /// The slot contents to test. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool Is(T? value) + { + if (typeof(T).IsValueType) + { + return EqualityComparer.Default.Equals(value, default); + } + + return value is null; + } +} diff --git a/src/Celerity/Collections/HashCachingDictionary.cs b/src/Celerity/Collections/HashCachingDictionary.cs index 0d91f74a..ac0663ab 100644 --- a/src/Celerity/Collections/HashCachingDictionary.cs +++ b/src/Celerity/Collections/HashCachingDictionary.cs @@ -786,7 +786,7 @@ public struct Enumerator : IEnumerator IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); private static bool IsDefaultKey(TKey key) => - EqualityComparer.Default.Equals(key, default(TKey)); + EmptySlot.Is(key); // The cached fingerprint for a key: its hash with the top bit forced set so // the stored metadata is always non-zero (zero is reserved for "empty"). diff --git a/src/Celerity/Collections/HashCachingSet.cs b/src/Celerity/Collections/HashCachingSet.cs index 7f8d5fb6..6edd754d 100644 --- a/src/Celerity/Collections/HashCachingSet.cs +++ b/src/Celerity/Collections/HashCachingSet.cs @@ -590,7 +590,7 @@ public void Dispose() { } } private static bool IsDefaultValue(T item) => - EqualityComparer.Default.Equals(item, default(T)); + EmptySlot.Is(item); // The cached fingerprint for an element: its hash with the top bit forced set // so the stored metadata is always non-zero (zero is reserved for "empty"). diff --git a/src/Celerity/Collections/PooledCelerityDictionary.cs b/src/Celerity/Collections/PooledCelerityDictionary.cs index c79fadc6..46bb096a 100644 --- a/src/Celerity/Collections/PooledCelerityDictionary.cs +++ b/src/Celerity/Collections/PooledCelerityDictionary.cs @@ -320,7 +320,6 @@ public bool ContainsValue(TValue? value) if (_hasDefaultKey && valueComparer.Equals(_defaultKeyValue, value)) return true; - var keyComparer = EqualityComparer.Default; TKey?[] keys = _keys; TValue?[] values = _values; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); @@ -330,7 +329,7 @@ public bool ContainsValue(TValue? value) int length = _size; for (int i = 0; i < length; i++) { - if (!keyComparer.Equals(Unsafe.Add(ref keysRef, (nint)(uint)i), default(TKey)) && + if (!EmptySlot.Is(Unsafe.Add(ref keysRef, (nint)(uint)i)) && valueComparer.Equals(Unsafe.Add(ref valuesRef, (nint)(uint)i), value)) { return true; @@ -722,11 +721,10 @@ public bool MoveNext() int length = _dict._size; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); ref TValue? valuesRef = ref MemoryMarshal.GetArrayDataReference(values); - var comparer = EqualityComparer.Default; while (++_index < length) { TKey? key = Unsafe.Add(ref keysRef, (nint)(uint)_index); - if (!comparer.Equals(key, default(TKey))) + if (!EmptySlot.Is(key)) { _current = new KeyValuePair(key!, Unsafe.Add(ref valuesRef, (nint)(uint)_index)); return true; @@ -878,7 +876,7 @@ public struct Enumerator : IEnumerator IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); private static bool IsDefaultKey(TKey key) => - EqualityComparer.Default.Equals(key, default(TKey)); + EmptySlot.Is(key); [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ThrowIfDisposed() @@ -919,7 +917,7 @@ private int ProbeForInsert(TKey key, out bool wasEmpty) while (true) { TKey? slot = Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slot, default(TKey))) { wasEmpty = true; return index; } + if (EmptySlot.Is(slot)) { wasEmpty = true; return index; } if (comparer.Equals(slot, key)) { wasEmpty = false; return index; } index = (index + 1) & mask; } @@ -937,7 +935,7 @@ private int ProbeForKey(TKey key) while (true) { TKey? slot = Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slot, default(TKey))) return -1; + if (EmptySlot.Is(slot)) return -1; if (comparer.Equals(slot, key)) return index; index = (index + 1) & mask; } @@ -967,15 +965,14 @@ private void Resize(int newSize) ref TKey? newKeysRef = ref MemoryMarshal.GetArrayDataReference(newKeys); ref TValue? newValuesRef = ref MemoryMarshal.GetArrayDataReference(newValues); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldSize; i++) { TKey? key = Unsafe.Add(ref oldKeysRef, (nint)(uint)i); - if (comparer.Equals(key, default(TKey))) + if (EmptySlot.Is(key)) continue; int index = _hasher.Hash(key!) & mask; - while (!comparer.Equals(Unsafe.Add(ref newKeysRef, (nint)(uint)index), default(TKey))) + while (!EmptySlot.Is(Unsafe.Add(ref newKeysRef, (nint)(uint)index))) index = (index + 1) & mask; Unsafe.Add(ref newKeysRef, (nint)(uint)index) = key; @@ -1004,7 +1001,6 @@ private void BackwardShiftRemove(int startIndex) ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); ref TValue? valuesRef = ref MemoryMarshal.GetArrayDataReference(values); int mask = _mask; - var comparer = EqualityComparer.Default; int i = startIndex; int j = i; @@ -1012,7 +1008,7 @@ private void BackwardShiftRemove(int startIndex) { j = (j + 1) & mask; TKey? candidateKey = Unsafe.Add(ref keysRef, (nint)(uint)j); - if (comparer.Equals(candidateKey, default(TKey))) + if (EmptySlot.Is(candidateKey)) break; int k = _hasher.Hash(candidateKey!) & mask; diff --git a/src/Celerity/Collections/PooledCeleritySet.cs b/src/Celerity/Collections/PooledCeleritySet.cs index aa8b43cf..411dc742 100644 --- a/src/Celerity/Collections/PooledCeleritySet.cs +++ b/src/Celerity/Collections/PooledCeleritySet.cs @@ -253,7 +253,7 @@ public bool TryAdd(T item) while (true) { T? slot = Unsafe.Add(ref slotsRef, (nint)(uint)index); - if (comparer.Equals(slot, default(T))) break; + if (EmptySlot.Is(slot)) break; if (comparer.Equals(slot, item)) return false; index = (index + 1) & mask; } @@ -265,7 +265,7 @@ public bool TryAdd(T item) slotsRef = ref MemoryMarshal.GetArrayDataReference(slots); mask = _mask; index = _hasher.Hash(item) & mask; - while (!comparer.Equals(Unsafe.Add(ref slotsRef, (nint)(uint)index), default(T))) + while (!EmptySlot.Is(Unsafe.Add(ref slotsRef, (nint)(uint)index))) index = (index + 1) & mask; } @@ -643,11 +643,10 @@ public bool MoveNext() // Bound by the logical size, not slots.Length (rented tail is garbage). int length = _set._size; ref T? slotsRef = ref MemoryMarshal.GetArrayDataReference(slots); - var comparer = EqualityComparer.Default; while (++_index < length) { T? slot = Unsafe.Add(ref slotsRef, (nint)(uint)_index); - if (!comparer.Equals(slot, default(T))) + if (!EmptySlot.Is(slot)) { _current = slot; return true; @@ -683,7 +682,7 @@ public void Dispose() { } } private static bool IsDefaultValue(T item) => - EqualityComparer.Default.Equals(item, default(T)); + EmptySlot.Is(item); [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ThrowIfDisposed() @@ -715,7 +714,7 @@ private int ProbeForItem(T item) while (true) { T? slot = Unsafe.Add(ref slotsRef, (nint)(uint)index); - if (comparer.Equals(slot, default(T))) return -1; + if (EmptySlot.Is(slot)) return -1; if (comparer.Equals(slot, item)) return index; index = (index + 1) & mask; } @@ -742,15 +741,14 @@ private void Resize(int newSize) ref T? oldSlotsRef = ref MemoryMarshal.GetArrayDataReference(oldSlots); ref T? newSlotsRef = ref MemoryMarshal.GetArrayDataReference(newSlots); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldSize; i++) { T? item = Unsafe.Add(ref oldSlotsRef, (nint)(uint)i); - if (comparer.Equals(item, default(T))) + if (EmptySlot.Is(item)) continue; int index = _hasher.Hash(item!) & mask; - while (!comparer.Equals(Unsafe.Add(ref newSlotsRef, (nint)(uint)index), default(T))) + while (!EmptySlot.Is(Unsafe.Add(ref newSlotsRef, (nint)(uint)index))) index = (index + 1) & mask; Unsafe.Add(ref newSlotsRef, (nint)(uint)index) = item; @@ -774,7 +772,6 @@ private void BackwardShiftRemove(int startIndex) T?[] slots = _slots; ref T? slotsRef = ref MemoryMarshal.GetArrayDataReference(slots); int mask = _mask; - var comparer = EqualityComparer.Default; int i = startIndex; int j = i; @@ -782,7 +779,7 @@ private void BackwardShiftRemove(int startIndex) { j = (j + 1) & mask; T? candidate = Unsafe.Add(ref slotsRef, (nint)(uint)j); - if (comparer.Equals(candidate, default(T))) + if (EmptySlot.Is(candidate)) break; int k = _hasher.Hash(candidate!) & mask; diff --git a/src/Celerity/Collections/RobinHoodDictionary.cs b/src/Celerity/Collections/RobinHoodDictionary.cs index cd11d73e..426d3231 100644 --- a/src/Celerity/Collections/RobinHoodDictionary.cs +++ b/src/Celerity/Collections/RobinHoodDictionary.cs @@ -287,7 +287,6 @@ public bool ContainsValue(TValue? value) if (_hasDefaultKey && valueComparer.Equals(_defaultKeyValue, value)) return true; - var keyComparer = EqualityComparer.Default; TKey?[] keys = _keys; TValue?[] values = _values; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); @@ -295,7 +294,7 @@ public bool ContainsValue(TValue? value) int length = keys.Length; for (int i = 0; i < length; i++) { - if (!keyComparer.Equals(Unsafe.Add(ref keysRef, (nint)(uint)i), default(TKey)) && + if (!EmptySlot.Is(Unsafe.Add(ref keysRef, (nint)(uint)i)) && valueComparer.Equals(Unsafe.Add(ref valuesRef, (nint)(uint)i), value)) { return true; @@ -615,11 +614,10 @@ public bool MoveNext() int length = keys.Length; ref TKey? keysRef = ref MemoryMarshal.GetArrayDataReference(keys); ref TValue? valuesRef = ref MemoryMarshal.GetArrayDataReference(values); - var comparer = EqualityComparer.Default; while (++_index < length) { TKey? key = Unsafe.Add(ref keysRef, (nint)(uint)_index); - if (!comparer.Equals(key, default(TKey))) + if (!EmptySlot.Is(key)) { _current = new KeyValuePair(key!, Unsafe.Add(ref valuesRef, (nint)(uint)_index)); return true; @@ -773,7 +771,7 @@ public struct Enumerator : IEnumerator IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); private static bool IsDefaultKey(TKey key) => - EqualityComparer.Default.Equals(key, default(TKey)); + EmptySlot.Is(key); // Robin Hood lookup. Walks the probe chain from the key's ideal slot and // stops on one of three conditions: an empty slot (key absent), a resident @@ -807,7 +805,7 @@ private int ProbeForKey(TKey key, int hash) while (true) { TKey? slot = Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slot, default(TKey))) + if (EmptySlot.Is(slot)) return -1; if (Unsafe.Add(ref distRef, (nint)(uint)index) < dist) return -1; @@ -839,14 +837,13 @@ private void InsertAbsent(TKey?[] keys, TValue?[] values, int[] distances, TKey ref TValue? valuesRef = ref MemoryMarshal.GetArrayDataReference(values); ref int distRef = ref MemoryMarshal.GetArrayDataReference(distances); int mask = keys.Length - 1; - var comparer = EqualityComparer.Default; int index = hash & mask; int dist = 0; while (true) { ref TKey? slotKey = ref Unsafe.Add(ref keysRef, (nint)(uint)index); - if (comparer.Equals(slotKey, default(TKey))) + if (EmptySlot.Is(slotKey)) { slotKey = key; Unsafe.Add(ref valuesRef, (nint)(uint)index) = value; @@ -902,11 +899,10 @@ private void Resize(int newSize) ref TKey? oldKeysRef = ref MemoryMarshal.GetArrayDataReference(oldKeys); ref TValue? oldValuesRef = ref MemoryMarshal.GetArrayDataReference(oldValues); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldKeys.Length; i++) { TKey? key = Unsafe.Add(ref oldKeysRef, (nint)(uint)i); - if (comparer.Equals(key, default(TKey))) + if (EmptySlot.Is(key)) continue; InsertAbsent(newKeys, newValues, newDistances, key!, Unsafe.Add(ref oldValuesRef, (nint)(uint)i)); @@ -934,7 +930,6 @@ private void BackwardShiftRemove(int startIndex) ref TValue? valuesRef = ref MemoryMarshal.GetArrayDataReference(values); ref int distRef = ref MemoryMarshal.GetArrayDataReference(distances); int mask = keys.Length - 1; - var comparer = EqualityComparer.Default; int i = startIndex; while (true) @@ -942,7 +937,7 @@ private void BackwardShiftRemove(int startIndex) int next = (i + 1) & mask; TKey? nextKey = Unsafe.Add(ref keysRef, (nint)(uint)next); int nextDist = Unsafe.Add(ref distRef, (nint)(uint)next); - if (comparer.Equals(nextKey, default(TKey)) || nextDist == 0) + if (EmptySlot.Is(nextKey) || nextDist == 0) break; Unsafe.Add(ref keysRef, (nint)(uint)i) = nextKey; diff --git a/src/Celerity/Collections/RobinHoodSet.cs b/src/Celerity/Collections/RobinHoodSet.cs index fab18369..faeff7ba 100644 --- a/src/Celerity/Collections/RobinHoodSet.cs +++ b/src/Celerity/Collections/RobinHoodSet.cs @@ -544,11 +544,10 @@ public bool MoveNext() T?[] items = _set._items; int length = items.Length; ref T? itemsRef = ref MemoryMarshal.GetArrayDataReference(items); - var comparer = EqualityComparer.Default; while (++_index < length) { T? item = Unsafe.Add(ref itemsRef, (nint)(uint)_index); - if (!comparer.Equals(item, default(T))) + if (!EmptySlot.Is(item)) { _current = item; return true; @@ -584,7 +583,7 @@ public void Dispose() { } } private static bool IsDefaultValue(T item) => - EqualityComparer.Default.Equals(item, default(T)); + EmptySlot.Is(item); // Robin Hood lookup. Walks the probe chain from the element's ideal slot and // stops on one of three conditions: an empty slot (element absent), a resident @@ -613,7 +612,7 @@ private int ProbeForItem(T item, int hash) while (true) { T? slot = Unsafe.Add(ref itemsRef, (nint)(uint)index); - if (comparer.Equals(slot, default(T))) + if (EmptySlot.Is(slot)) return -1; if (Unsafe.Add(ref distRef, (nint)(uint)index) < dist) return -1; @@ -640,14 +639,13 @@ private void InsertAbsent(T?[] items, int[] distances, T item, int hash) ref T? itemsRef = ref MemoryMarshal.GetArrayDataReference(items); ref int distRef = ref MemoryMarshal.GetArrayDataReference(distances); int mask = items.Length - 1; - var comparer = EqualityComparer.Default; int index = hash & mask; int dist = 0; while (true) { ref T? slotItem = ref Unsafe.Add(ref itemsRef, (nint)(uint)index); - if (comparer.Equals(slotItem, default(T))) + if (EmptySlot.Is(slotItem)) { slotItem = item; Unsafe.Add(ref distRef, (nint)(uint)index) = dist; @@ -692,11 +690,10 @@ private void Resize(int newSize) int[] newDistances = new int[newSize]; ref T? oldItemsRef = ref MemoryMarshal.GetArrayDataReference(oldItems); - var comparer = EqualityComparer.Default; for (int i = 0; i < oldItems.Length; i++) { T? item = Unsafe.Add(ref oldItemsRef, (nint)(uint)i); - if (comparer.Equals(item, default(T))) + if (EmptySlot.Is(item)) continue; InsertAbsent(newItems, newDistances, item!, _hasher.Hash(item!)); @@ -721,7 +718,6 @@ private void BackwardShiftRemove(int startIndex) ref T? itemsRef = ref MemoryMarshal.GetArrayDataReference(items); ref int distRef = ref MemoryMarshal.GetArrayDataReference(distances); int mask = items.Length - 1; - var comparer = EqualityComparer.Default; int i = startIndex; while (true) @@ -729,7 +725,7 @@ private void BackwardShiftRemove(int startIndex) int next = (i + 1) & mask; T? nextItem = Unsafe.Add(ref itemsRef, (nint)(uint)next); int nextDist = Unsafe.Add(ref distRef, (nint)(uint)next); - if (comparer.Equals(nextItem, default(T)) || nextDist == 0) + if (EmptySlot.Is(nextItem) || nextDist == 0) break; Unsafe.Add(ref itemsRef, (nint)(uint)i) = nextItem; diff --git a/src/Celerity/Collections/SwissDictionary.cs b/src/Celerity/Collections/SwissDictionary.cs index 2ab579b0..99580092 100644 --- a/src/Celerity/Collections/SwissDictionary.cs +++ b/src/Celerity/Collections/SwissDictionary.cs @@ -793,7 +793,7 @@ public struct Enumerator : IEnumerator IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); private static bool IsDefaultKey(TKey key) => - EqualityComparer.Default.Equals(key, default(TKey)); + EmptySlot.Is(key); // SIMD group lookup for a non-default key. Walks the aligned-group probe // sequence from the key's home group: a single Vector128 compare turns each diff --git a/src/Celerity/Collections/SwissSet.cs b/src/Celerity/Collections/SwissSet.cs index 7c12cf5a..c95872cc 100644 --- a/src/Celerity/Collections/SwissSet.cs +++ b/src/Celerity/Collections/SwissSet.cs @@ -607,7 +607,7 @@ public void Dispose() { } } private static bool IsDefaultValue(T item) => - EqualityComparer.Default.Equals(item, default(T)); + EmptySlot.Is(item); // SIMD group lookup for a non-default element. Walks the aligned-group probe // sequence from the element's home group: a single Vector128 compare turns each diff --git a/web/dev/bench/detail.html b/web/dev/bench/detail.html index 44d0e1c9..683182c2 100644 --- a/web/dev/bench/detail.html +++ b/web/dev/bench/detail.html @@ -386,7 +386,10 @@ { key: 'Trie', title: 'Trie', vs: 'Dictionary' }, { key: 'FenwickTree', title: 'FenwickTree', vs: 'long[] (naive prefix sum)' }, { key: 'BTreeDictionary', title: 'BTreeDictionary', vs: 'SortedDictionary' }, - { key: 'BTreeSet', title: 'BTreeSet', vs: 'SortedSet' } + { key: 'BTreeSet', title: 'BTreeSet', vs: 'SortedSet' }, + // StringKeyProbe is the reference-type-key probe path (every other card here keys on int/long). + // Lookup / LookupMissing are CelerityDictionary; Contains is CeleritySet. + { key: 'StringKeyProbe', title: 'String-keyed probe', vs: 'Dictionary / HashSet' } ]; // Baseline (non-Celerity) type names, as they appear in the `_` benchmark method names. // 'Array' is the plain-array reference the FenwickTree benchmark measures against — the BCL has no diff --git a/web/dev/bench/index.html b/web/dev/bench/index.html index 0364eac0..9ebf6bd9 100644 --- a/web/dev/bench/index.html +++ b/web/dev/bench/index.html @@ -467,7 +467,12 @@

Hash function throughput

// Mixed (interleaved insert + lookup + in-order range scan) is the documented win workload; RangeScan is // the operation SortedDictionary cannot express at all. { key: 'BTreeDictionary', title: 'BTreeDictionary', vs: 'SortedDictionary', ops: ['Add', 'Lookup', 'Remove', 'RangeScan', 'Mixed'] }, - { key: 'BTreeSet', title: 'BTreeSet', vs: 'SortedSet', ops: ['Add', 'Contains', 'Remove', 'RangeScan', 'Mixed'] } + { key: 'BTreeSet', title: 'BTreeSet', vs: 'SortedSet', ops: ['Add', 'Contains', 'Remove', 'RangeScan', 'Mixed'] }, + // StringKeyProbe tracks the reference-type-key probe path — every other card here keys on int/long, + // where the JIT devirtualizes the per-probe comparison and the codegen question does not arise. + // Lookup / LookupMissing are CelerityDictionary; Contains is CeleritySet. + // LookupMissing is the probe-heavy arm: a miss walks the cluster to the first vacant slot. + { key: 'StringKeyProbe', title: 'String-keyed probe', vs: 'Dictionary / HashSet', ops: ['Lookup', 'LookupMissing', 'Contains'] } ]; // Baseline (non-Celerity) type names, as they appear in the `_` benchmark method names. From 9322bda61d38a2021336fa540bdfdb1f4ca98b3f Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Sun, 26 Jul 2026 11:11:49 +0300 Subject: [PATCH 2/2] docs(changelog): tighten the #308 entries to the observable change Copilot review on #322: the [Unreleased] bullets recited the JIT/codegen detail and enumerated every affected type, against CLAUDE.md's rule that entries stay short and user-facing (the release workflow extracts the section verbatim). --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ddedcb..69f7fe5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,14 @@ All notable changes to Celerity are documented here. This project follows [Keep ### Added -- **`StringKeyProbeBenchmark`** — the tracked benchmark suite's first `string`-keyed dictionary and set rows (lookup hit, lookup miss, set `Contains`), registered in the core suite and rendered as the **String-keyed probe** card on the benchmark dashboard. Every other card keys on `int` / `long`. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). -- **`ReferenceKeyProbeTests`** — cross-collection coverage pinning vacant-slot detection on all twelve open-addressed collections against a key type whose `Equals` claims equality with `null`, plus the `null`-key round trip on each. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). +- **`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). +- **`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). - **`BTreeDictionary` and `BTreeSet`** (with `BTreeDictionary` / `BTreeSet` aliases and the `DefaultComparer` 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). ### Changed -- Lookups on **reference-type keys** are faster across the open-addressed collections (`CelerityDictionary`, `RobinHoodDictionary`, `SwissDictionary`, `HashCachingDictionary`, `PooledCelerityDictionary`, `CelerityMultiMap` and their set counterparts): testing whether a slot is vacant no longer costs an interface call per probe iteration. Behaviour is identical and value-type keys are unaffected; locally, in-cache `string`-keyed lookups improved ~10%. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). -- The performance guide and the README now state plainly that `CelerityDictionary` measures **behind** the BCL `Dictionary` on `string` keys — the BCL stores a hash code per entry — and point `string`-keyed workloads at `HashCachingDictionary`, which closes the gap and wins outright on negative lookups. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308). +- 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). +- 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). ## [2.4.0] - 2026-07-26