Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ All notable changes to Celerity are documented here. This project follows [Keep

### Added

- **`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<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).

### Changed

- 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).

### Fixed

- **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).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,10 +547,10 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
|---|---|---|
| Dictionary keyed by `int` | `IntDictionary<TValue>` | Avoids generic boxing / `EqualityComparer<int>` dispatch; defaults to `Int32WangNaiveHasher`. |
| Dictionary keyed by `long` | `LongDictionary<TValue>` | 64-bit equivalent of `IntDictionary`; defaults to `Int64WangNaiveHasher`. |
| 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. |
| 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). |
| 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`. |
| **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. |
| **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. |
| **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). |
| **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`. |
| 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. |
| 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`. |
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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).
- 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`.
- 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`.
- 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).
- 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`.

**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.
Expand Down
2 changes: 2 additions & 0 deletions docs/api/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TValue>` already stores a hash code per entry, so `CelerityDictionary<string, …>` — 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
Expand Down
15 changes: 15 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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:

| Workload (100k identifier-shaped `string` keys) | vs `Dictionary<string, int>` |
|---|---|
| `CelerityDictionary<string, int, StringXxHash3Hasher>` lookup (hit) | 1.33× slower |
| `CelerityDictionary<string, int, StringXxHash3Hasher>` lookup (miss) | 1.60× slower |
| `HashCachingDictionary<string, int, StringXxHash3Hasher>` lookup (hit) | 1.18× slower |
| `HashCachingDictionary<string, int, StringXxHash3Hasher>` lookup (miss) | **0.94× — faster** |

`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.

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:
Expand Down
1 change: 1 addition & 0 deletions src/Celerity.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ internal class Program
typeof(FenwickTreeBenchmark),
typeof(BTreeDictionaryBenchmark),
typeof(BTreeSetBenchmark),
typeof(StringKeyProbeBenchmark),
typeof(StringHasherBenchmark),
typeof(IntegerHasherBenchmark),
};
Expand Down
137 changes: 137 additions & 0 deletions src/Celerity.Benchmarks/StringKeyProbeBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using Celerity.Collections;
using Celerity.Hashing;

/// <summary>
/// The reference-type-key probe path, which the rest of the tracked suite does not cover: every other
/// dictionary / set benchmark here keys on <c>int</c> or <c>long</c>.
/// </summary>
/// <remarks>
/// <para>
/// A value-type key lets the JIT devirtualize and inline <c>EqualityComparer&lt;T&gt;.Default</c>, so the
/// probe body is straight-line code. A reference-type key does not: the collection JITs as a
/// <c>__Canon</c>-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.
/// </para>
/// <para>
/// <c>LookupMissing</c> 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.
/// </para>
/// </remarks>
[MemoryDiagnoser(false)]
[CategoriesColumn]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
public class StringKeyProbeBenchmark
{
private string[] keys = null!;
private string[] missingKeys = null!;

private Dictionary<string, int> dictionary = null!;
private CelerityDictionary<string, int, StringXxHash3Hasher> celerityDictionary = null!;

private HashSet<string> hashSet = null!;
private CeleritySet<string, StringXxHash3Hasher> 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<string, int>(ItemCount);
celerityDictionary = new CelerityDictionary<string, int, StringXxHash3Hasher>(ItemCount);
hashSet = new HashSet<string>(ItemCount);
celeritySet = new CeleritySet<string, StringXxHash3Hasher>(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;
}
}
Loading
Loading