Skip to content

Commit 98a6426

Browse files
Merge pull request #167 from marius-bughiu/feat/issue-61-small-dictionary
feat(collections): add SmallDictionary<TKey, TValue> flat-array dictionary (#61)
2 parents 3cdfbb3 + 343b8ae commit 98a6426

24 files changed

Lines changed: 2098 additions & 19 deletions

CHANGELOG.md

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

77
### Added
88

9+
- `SmallDictionary<TKey, TValue>` in `Celerity.Collections` — a dictionary tuned for the very-small (`n <= ~16`) case, where a linear scan over a flat backing array beats a probe-based hash table (the shape compilers, IL emitters, AST attribute bags, and per-request maps hit constantly). The 1.2.0 small-collection ([#61](https://github.com/marius-bughiu/Celerity/issues/61)). It stores entries in insertion-dense parallel `TKey?[]` / `TValue?[]` arrays and answers every query with a linear scan using `EqualityComparer<TKey>.Default`, so there is **no hasher** (no `THasher` type parameter): nothing is hashed, which means there is no empty-slot sentinel and therefore **no out-of-band default-key slot** — a `0` / `null` / `Guid.Empty` key is stored inline like any other, a deliberate simplification over the hash-table dictionaries. The trade-off is that lookups / `Add` / `TryAdd` / `ContainsKey` / `Remove` are `O(n)` rather than `O(1)`, so the type is for small key sets and is documented as degrading for large ones (it does not auto-promote to a hash table; it grows its arrays and keeps scanning). `Remove` swaps the last entry into the vacated slot (an `O(1)` move once the key is found), so enumeration order is unspecified; a pure indexer overwrite never grows the arrays. Public API mirrors the other Celerity dictionaries: indexer get/set (get returns the non-nullable `TValue` and throws `KeyNotFoundException` on miss), `ContainsKey`, `ContainsValue`, `TryGetValue`, `Add`, `TryAdd`, `Remove` (both `bool Remove(key)` and `bool Remove(key, out TValue?)`), `Clear`, `Count`, allocation-free struct `Keys` / `Values` views and `Enumerator` (with `_version` mutation detection), `IReadOnlyDictionary<TKey, TValue?>`, and an `IEnumerable<KeyValuePair<TKey, TValue>>` constructor (duplicate keys throw `ArgumentException`; a `null` source throws `ArgumentNullException`, checked before the capacity validation). The constructor takes a `capacity` (used verbatim, not rounded to a power of two, since there is no probe mask; `0` defers allocation) and — unlike the hash-table dictionaries — has **no `loadFactor`** parameter. AOT-safe (no reflection); hot-path lookup is allocation-free.
10+
- `SmallDictionaryTests` and `SmallDictionaryEnumerationTests` — dedicated suites mirroring `IntDictionaryTests` / `IntDictionaryEnumerationTests`, adapted for a hasher-less type: indexer insert/retrieve/overwrite (and overwrite-at-capacity not growing), `Remove` from first / middle / last slot via the swap-with-last path, grow-on-capacity-exceeded, the inline `0` / `null` default key exercised as an ordinary entry, `TryGetValue` hit/miss, `Clear` (including the already-empty no-op), remove-then-reinsert fidelity, zero-capacity deferred allocation, and the full enumeration surface (yield-once, reflect removal/clear, survive growth, mid-enumeration mutation/remove/clear detection on `MoveNext` and `Reset`, `Keys` / `Values` views and counts, the boxed generic and non-generic `IEnumerable` paths, and `Reset` reuse). A dedicated `*CollisionTests` file is intentionally **not** added: a linear-scan dictionary has no hashing and therefore no collisions to test.
11+
- A `SmallDictionary` parity arm added to the differential testing layer: a CsCheck model property test (`CollectionModelPropertyTests.SmallDictionary_ShouldMatch_BclDictionary`, against a `Dictionary<int, int>` oracle over a random Set/Remove/TryAdd/Clear op stream) and a `Celerity.Fuzz` target (`SmallDictionary`, registered in `Differential.All`) that fuzzes the same op stream against the BCL oracle and cross-checks count, per-key lookups, and duplicate-free enumeration.
12+
- Cross-collection shared tests extended to cover `SmallDictionary`: `AddAndTryAddTests` (Add/TryAdd new/duplicate/zero-key, duplicate leaves value unchanged), `ConstructorValidationTests` (negative-capacity throw and zero-capacity accept — the loadFactor rows genuinely do not apply, as `SmallDictionary` has no load factor), `ContainsValueTests` (empty-map false, regular-slot hit, default/zero value, post-`Remove` the swapped-out value does not linger, `null` value), `IEnumerableConstructorTests` (null-source `ArgumentNullException` with `paramName` `"source"`, duplicate-key `ArgumentException`, array / non-collection-enumerable copy, large-source fidelity), `IEnumerableConstructorNullPriorityTests` (null source beats the only other ctor validation it has — a negative `capacity`), `IndexerReturnTypeTests` (the primary indexer's declared return type is the non-nullable `TValue`), `ReadOnlyDictionaryInterfaceTests` (assignability, interface indexer / `TryGetValue` / `Keys` / `Values` / generic + non-generic enumeration / LINQ `Count`, and the polymorphic `IReadOnlyDictionary<int,int>` consumer), `RemoveOutValueTests` (captured-value path, missing-key / empty-map false, default-value capture, middle-slot removal keeping the rest, remove-then-reinsert, version bump invalidating an active enumerator), and `TryAddDuplicateResizeTests` (a duplicate `TryAdd` at exactly full capacity is a no-op that keeps an active enumerator valid; a new-key `TryAdd` that grows the arrays invalidates it). The hash-specific shared files do not apply and are intentionally not extended: `LoadFactorBoundaryTests` (no load factor), `TryAddProbeCountTests` and `IndexerOverwriteResizeTests` (both count `IHashProvider.Hash` calls — there is no hasher), and the `Set*` files (sets only).
13+
- `SmallDictionaryBenchmark` in `Celerity.Benchmarks` — an `Insert` / `Lookup` / `Remove` comparison of `SmallDictionary<int, int>` against the BCL `Dictionary<int, int>` baseline. Unlike the hash-table benchmarks it uses **small** `[Params(8, 64)]` item counts rather than `1000 / 100_000`: `SmallDictionary` is a small-`n` collection whose `O(n)` scan is meant to win at low `n` and lose as `n` grows (and an `O(n²)` insert sweep at 100k would be both meaningless and far too slow for CI), so 8 (a clear win) and 64 (into the crossover region) show both sides honestly. Same per-`Remove` `[IterationSetup]` rebuild pattern as the other collection benchmarks. Registered in `Celerity.Benchmarks/Program.cs`'s `CoreBenchmarks` array so it joins the `RunAllJoined` CI report consumed by `github-action-benchmark` and published to the gh-pages benchmark history on every push to `main`.
14+
- Dashboard wiring for `SmallDictionary`: a "What ships in the box" ship card in `web/index.html`, and a `COLLECTIONS` entry in both `web/dev/bench/index.html` (key / title / vs `Dictionary<int, int>` / ops `['Insert', 'Lookup', 'Remove']`) and `web/dev/bench/detail.html` (key / title / vs). Because `SmallDictionary` is benchmarked at `8 / 64` items rather than the `1000 / 100_000` the hash tables use, both dashboards were generalized to support **per-collection item counts**: each `COLLECTIONS` entry may now carry an optional `items` array (defaulting to `[1000, 100000]` when absent, preserving every existing collection's behaviour), and the chart cells, headline detail strings, detail-page item-count toggle buttons, and URL/param validation read from it. The baseline series parses as the existing `Dictionary` `BCL_TYPES` entry (the benchmark's baseline methods are named `Dictionary_*`), so no `BCL_TYPES` change is needed.
15+
- `docs/api/collections.md` and `README.md` — a full `SmallDictionary` section in the API reference (the no-hasher / flat-array design and its trade-offs, constructors with the verbatim `capacity` and the absent `loadFactor`, `Count`, the indexer, every method, the inline default-key handling, and a runnable per-scope-symbol-table example), the README Collections list and "Choosing a collection" decision table updated to ship it (with the small-`n`-only guidance), a new "the tiny-map fast path" Quick start subsection, and the "all collections handle `default(TKey)` out-of-band" note corrected to carve out `SmallDictionary`'s inline default key. The `Celerity.AotSmokeTest` now constructs `SmallDictionary<int, int>` (indexer / Add / TryAdd / Remove / inline zero key / enumeration) and `SmallDictionary<string, int>` (the `IEnumerable` ctor and inline `null` key) so the Native AOT publish job compiles the new generic instantiations.
16+
917
- Expanded the benchmark project (`src/Celerity.Benchmarks`) with an **extended, on-demand suite** that goes beyond the single random-key comparison ([#26](https://github.com/marius-bughiu/Celerity/issues/26), [#60](https://github.com/marius-bughiu/Celerity/issues/60)). New `KeyDistributions` helper generates **uniform / sequential / clustered / adversarial** integer and long key sets, and eight new benchmark classes use them: `DistributionBenchmark` (insert/lookup across distributions), `AdversarialHasherBenchmark` (shows `Int32WangNaiveHasher` degrading to O(n) on engineered collisions while `Int32Murmur3Hasher` stays O(1)), `LargeDatasetBenchmark` (1M/5M items), `MemoryAllocationBenchmark` (grow-vs-presized allocations with the full `MemoryDiagnoser` columns), `ConcurrentAccessBenchmark` (read scaling at 1/4/8 threads vs `ConcurrentDictionary<,>`), `CacheLocalityBenchmark` (in-order vs shuffled probing), `LibraryComparisonBenchmark` (vs the BCL `FrozenDictionary<,>`), and `RealWorldWorkloadBenchmark` (mixed ~80/12/8 read/write/remove stream with a hot key set). These run on demand (e.g. `dotnet run -c Release -- --filter "*Distribution*"`) and are **kept out of the per-PR CI regression run** so the same-runner A/B comparison stays fast and low-variance; `Program.cs` now splits a CI-tracked `CoreBenchmarks` set from the local-only `ExtendedBenchmarks` set. In CI they additionally run **weekly** (and on demand) via a new `benchmarks-extended.yml` workflow (`--ci-extended`), which publishes to a **separate `dev/bench-extended` dashboard** on gh-pages — linked as *Extended* in the site nav and kept apart from the per-commit core trend. Documented in [`docs/performance.md`](docs/performance.md#extended-benchmark-suite).
1018
- `CelerityMultiMap<TKey, TValue, THasher>` in `Celerity.Collections` — a one-to-many map (multi-map / multi-dictionary): each key maps to an ordered *group* of values rather than a single value. The 1.2.0 one-to-many collection ([#18](https://github.com/marius-bughiu/Celerity/issues/18)). It reuses `CelerityDictionary`'s open-addressed, linear-probing key table and the same `where THasher : struct, IHashProvider<TKey>` constraint (so the JIT devirtualizes and inlines the key hash), storing a `List<TValue?>` value group alongside each key slot. `Add(key, value)` **always appends** — adding the same key twice groups the values, and adding the same value twice under one key keeps both copies. Public API: `Add`, `AddRange`, `Remove(key, value)` (removes one occurrence; removes the key when its group empties), `RemoveAll(key)` (removes the key and all its values), `Clear`, `ContainsKey`, `Contains(key, value)`, `ContainsValue(value)`, `CountValues(key)`, `TryGetValues(key, out ValueGroup)`, the `ValueGroup this[key]` indexer (returns an **empty group** for an absent key, matching `ILookup` semantics, rather than throwing), `Count` (distinct keys) and `ValueCount` (total values), an allocation-free struct `Enumerator` yielding one `Grouping` (`IGrouping<TKey, TValue?>`) per key, and a `Keys` view. Reads are allocation-free (the indexer / `TryGetValues` hand back a lightweight `ValueGroup` struct over the live backing list); the write path allocates one backing list per distinct key, inherent to storing a group per key. Implements `ILookup<TKey, TValue?>`, so it flows through LINQ. `default(TKey)` (`null` / `0` / `Guid.Empty`) is stored out-of-band — the hasher is never invoked with it, so it never collides with the empty-slot sentinel — and behaves as an ordinary key (yielded first in enumeration). The `IEnumerable<KeyValuePair<TKey, TValue>>` constructor groups duplicate keys in source order (duplicate keys are not an error, unlike the dictionaries); a `null` source throws `ArgumentNullException`, and `capacity` / `loadFactor` are validated exactly as the dictionaries. AOT-safe (no reflection).
1119
- `CelerityMultiMapTests`, `CelerityMultiMapCollisionTests`, and `CelerityMultiMapEnumerationTests` — dedicated suites mirroring the `CelerityDictionary*` files for the grouping semantics: grouping Adds (including duplicate keys and duplicate values), the indexer/`TryGetValues` empty-on-miss group views, `Contains(key, value)` / `ContainsValue` / `CountValues`, both removal shapes (`Remove(key, value)` collapsing a key when its last value is removed, and `RemoveAll(key)`), `Clear`, `Count` vs `ValueCount`, `AddRange`, the out-of-band default-key group for `string` (`null`), `int` (`0`), and `Guid` (`Guid.Empty`), the `IEnumerable<KeyValuePair<,>>` grouping constructor, and the `ILookup<,>` surface; collision tests under a constant hasher (and an identity hasher for the wrapped-cluster branch) that assert grouping stays distinct under a single probe chain and that backward-shift deletion on a collapsed key never orphans the others; and enumeration tests for the struct `Grouping` enumerator (default-key group first), the per-group `ValueGroup` enumerator, the `Keys` view, mid-enumeration mutation detection (including a value-only `Add`/`Remove` on an existing key), and the boxed `IEnumerable<IGrouping<,>>` / non-generic paths.

0 commit comments

Comments
 (0)