Skip to content

Commit e5f54b9

Browse files
committed
Single-probe TryAdd across all four collections
Previously TryAdd (and therefore Add) walked the probe chain twice on every call: once via ContainsKey/Contains, then again via the indexer setter / InsertNonZero / InsertNonDefault. Replace both walks with a single ProbeForInsert-style walk that either lands on the existing entry (return false) or on the first empty slot (insert in place). Affects IntDictionary, CelerityDictionary, IntSet, and CeleritySet. Behaviour is identical -- duplicate-key Add still throws, TryAdd still returns false on duplicates and leaves the existing value untouched -- but bulk-loads via the new IEnumerable<KeyValuePair> constructor and any Add-heavy hot path now do half the probe work. Pinned by TryAddProbeCountTests, which uses a counting IHashProvider to assert that TryAdd calls Hash exactly once on both the new-key and duplicate-key paths across all four collections. Closes issue #23.
1 parent 4378904 commit e5f54b9

7 files changed

Lines changed: 334 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ All notable changes to Celerity are documented here. This project follows [Keep
5353

5454
### Changed
5555

56+
- `TryAdd` (and therefore `Add`) on `IntDictionary<TValue, THasher>`, `CelerityDictionary<TKey, TValue, THasher>`, `IntSet<THasher>`, and `CeleritySet<T, THasher>` now walks the probe chain exactly **once** per call instead of twice. The previous implementation called `ContainsKey` / `Contains` followed by the indexer setter / `InsertNon*` helper, each starting its own probe walk; the rewrite uses a single `ProbeForInsert`-style walk that either lands on the existing entry (return `false`) or on the first empty slot (insert in place). Behaviour is identical to before — including the duplicate-key contract on `Add` and the "unchanged on duplicate" contract on `TryAdd` — but bulk-loads via the new `IEnumerable<KeyValuePair<,>>` constructor and any `Add`-heavy hot path now do roughly half the probe work. Closes issue #23. Pinned by `TryAddProbeCountTests`, which uses a counting `IHashProvider` to assert that `TryAdd` calls `Hash` exactly once on both the new-key and duplicate-key paths across all four collections.
5657
- The `IntDictionary` `EMPTY_VALUE` field is now `static readonly` instead of an instance field. No behavior change; just removes per-instance overhead.
5758

5859
## [0.1.0] - initial releases

ISSUES.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,59 @@ Both constructors now throw `ArgumentOutOfRangeException` for `capacity < 0`, `l
222222
- **#18** — Robin Hood probing experiment (0.6.0).
223223
- **#19** — SIMD-accelerated probing experiment (0.6.0).
224224
- **#20** — Struct-of-arrays layout experiment (0.6.0).
225+
226+
---
227+
228+
## #23`TryAdd` / `Add` walk the probe chain twice on every insert
229+
230+
- **type**: perf / code-quality
231+
- **severity**: medium
232+
- **milestone**: 1.1.0
233+
- **status**: fixed in 1.1.0
234+
235+
### Description
236+
237+
`TryAdd(key, value)` (on both dictionaries and both sets) is currently implemented as:
238+
239+
```csharp
240+
public bool TryAdd(int key, TValue value)
241+
{
242+
// ... handle out-of-band zero/default key ...
243+
if (ContainsKey(key)) // probe 1 — full ProbeForKey walk
244+
return false;
245+
this[key] = value; // probe 2 — full ProbeForInsert walk inside the indexer setter
246+
return true;
247+
}
248+
```
249+
250+
That's two independent walks of the probe chain on the *common* path (the new-key path) when one would suffice. The same shape applies to `IntSet.TryAdd` / `CeleritySet.TryAdd` (both call `Contains` then `InsertNonZero` / `InsertNonDefault`).
251+
252+
The double probe matters in three places:
253+
254+
1. `Add(key, value)` — delegates to `TryAdd`, so every duplicate-throwing insert pays the same tax.
255+
2. The new `IEnumerable<KeyValuePair<TKey, TValue>>` constructor (added in 1.1.0) — bulk-loads via `Add`, so loading an N-element source does ~2N probe-chain walks instead of N. This is a regression on the headline 1.1.0 ergonomic API.
256+
3. Any caller doing `if (!dict.TryAdd(...)) ...` on a hot path.
257+
258+
### Fix
259+
260+
A single `ProbeForInsert(key)` call already returns either:
261+
262+
- the index of an existing entry (`_keys[index] == key`), or
263+
- the first empty slot in the probe chain (`_keys[index] == EMPTY_KEY` / `default(TKey)`).
264+
265+
`TryAdd` only needs to inspect that slot. If it's not empty, the key already exists → return false. Otherwise insert at that slot directly:
266+
267+
```csharp
268+
if (_count >= _threshold) Resize();
269+
int index = ProbeForInsert(key);
270+
if (_keys[index] != EMPTY_KEY) return false; // already present
271+
_keys[index] = key;
272+
_values[index] = value;
273+
_count++;
274+
_version++;
275+
return true;
276+
```
277+
278+
`Add(key, value)` is left as a thin throwing wrapper around `TryAdd`. Behaviour is identical to before; the only change is the elimination of the redundant probe walk.
279+
280+
A regression test using a probe-counting `IHashProvider` should accompany the fix — it pins the contract "TryAdd does exactly one probe walk on the new-key path" so a future refactor can't quietly re-introduce the doubling.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
using Celerity.Collections;
2+
using Celerity.Hashing;
3+
4+
namespace Celerity.Tests.Collections;
5+
6+
/// <summary>
7+
/// Regression tests for issue #23. <see cref="IntDictionary{TValue, THasher}.TryAdd"/>,
8+
/// <see cref="CelerityDictionary{TKey, TValue, THasher}.TryAdd"/>,
9+
/// <see cref="IntSet{THasher}.TryAdd"/>, and
10+
/// <see cref="CeleritySet{T, THasher}.TryAdd"/> historically walked the probe chain
11+
/// twice on the new-key path: once via <c>ContainsKey</c>/<c>Contains</c>, then again
12+
/// via the indexer setter / <c>InsertNon*</c> helper. The fix collapses both walks
13+
/// into a single <c>ProbeForInsert</c>. These tests pin that contract by counting how
14+
/// many times the underlying <see cref="IHashProvider{T}"/>'s <c>Hash</c> method is
15+
/// called: each probe-chain walk starts with exactly one <c>Hash</c> call (the
16+
/// subsequent linear-probe steps don't re-hash), so hash-call count is a faithful
17+
/// proxy for probe-chain walk count.
18+
///
19+
/// All tests pre-size the collection so no <c>Resize</c> happens during the asserted
20+
/// region — <c>Resize</c> would otherwise add hash calls of its own as it re-inserts
21+
/// the existing entries into the new array.
22+
/// </summary>
23+
public class TryAddProbeCountTests
24+
{
25+
private static int _hashCallCount;
26+
27+
/// <summary>
28+
/// A counting hasher for <see cref="int"/> keys. The implementation is a
29+
/// minimal Wang-style mix so distinct keys distribute across the table; the
30+
/// only test-relevant aspect is that each call increments a static counter.
31+
/// </summary>
32+
private struct CountingIntHasher : IHashProvider<int>
33+
{
34+
public int Hash(int key)
35+
{
36+
_hashCallCount++;
37+
unchecked
38+
{
39+
uint x = (uint)key;
40+
x = ((x >> 16) ^ x) * 0x45d9f3b;
41+
x = ((x >> 16) ^ x) * 0x45d9f3b;
42+
x = (x >> 16) ^ x;
43+
return (int)x;
44+
}
45+
}
46+
}
47+
48+
/// <summary>
49+
/// A counting hasher for <see cref="string"/> keys.
50+
/// </summary>
51+
private struct CountingStringHasher : IHashProvider<string>
52+
{
53+
public int Hash(string key)
54+
{
55+
_hashCallCount++;
56+
// Deliberately use a stable, deterministic mix; we don't care about
57+
// distribution for these tests, only that the call is counted.
58+
return key.GetHashCode();
59+
}
60+
}
61+
62+
[Fact]
63+
public void IntDictionary_TryAdd_NewKey_DoesExactlyOneProbeWalk()
64+
{
65+
// Pre-size so the asserted inserts never resize.
66+
var map = new IntDictionary<int, CountingIntHasher>(capacity: 64);
67+
_hashCallCount = 0;
68+
69+
// 10 brand-new (non-zero) keys. Pre-fix this allocated 20 hash calls.
70+
for (int i = 1; i <= 10; i++)
71+
Assert.True(map.TryAdd(i, i * 10));
72+
73+
Assert.Equal(10, _hashCallCount);
74+
Assert.Equal(10, map.Count);
75+
}
76+
77+
[Fact]
78+
public void IntDictionary_TryAdd_DuplicateKey_DoesExactlyOneProbeWalk()
79+
{
80+
var map = new IntDictionary<int, CountingIntHasher>(capacity: 64);
81+
for (int i = 1; i <= 5; i++)
82+
map.TryAdd(i, i);
83+
84+
_hashCallCount = 0;
85+
for (int i = 1; i <= 5; i++)
86+
Assert.False(map.TryAdd(i, -1));
87+
88+
Assert.Equal(5, _hashCallCount);
89+
// Original values must remain untouched on the duplicate path.
90+
for (int i = 1; i <= 5; i++)
91+
Assert.Equal(i, map[i]);
92+
}
93+
94+
[Fact]
95+
public void IntDictionary_Add_NewKey_DoesExactlyOneProbeWalk()
96+
{
97+
var map = new IntDictionary<int, CountingIntHasher>(capacity: 64);
98+
_hashCallCount = 0;
99+
100+
for (int i = 1; i <= 10; i++)
101+
map.Add(i, i);
102+
103+
Assert.Equal(10, _hashCallCount);
104+
Assert.Equal(10, map.Count);
105+
}
106+
107+
[Fact]
108+
public void CelerityDictionary_TryAdd_NewKey_DoesExactlyOneProbeWalk()
109+
{
110+
var map = new CelerityDictionary<string, int, CountingStringHasher>(capacity: 64);
111+
_hashCallCount = 0;
112+
113+
for (int i = 1; i <= 10; i++)
114+
Assert.True(map.TryAdd($"k{i}", i));
115+
116+
Assert.Equal(10, _hashCallCount);
117+
Assert.Equal(10, map.Count);
118+
}
119+
120+
[Fact]
121+
public void CelerityDictionary_TryAdd_DuplicateKey_DoesExactlyOneProbeWalk()
122+
{
123+
var map = new CelerityDictionary<string, int, CountingStringHasher>(capacity: 64);
124+
for (int i = 1; i <= 5; i++)
125+
map.TryAdd($"k{i}", i);
126+
127+
_hashCallCount = 0;
128+
for (int i = 1; i <= 5; i++)
129+
Assert.False(map.TryAdd($"k{i}", -1));
130+
131+
Assert.Equal(5, _hashCallCount);
132+
for (int i = 1; i <= 5; i++)
133+
Assert.Equal(i, map[$"k{i}"]);
134+
}
135+
136+
[Fact]
137+
public void IntSet_TryAdd_NewItem_DoesExactlyOneProbeWalk()
138+
{
139+
var set = new IntSet<CountingIntHasher>(capacity: 64);
140+
_hashCallCount = 0;
141+
142+
for (int i = 1; i <= 10; i++)
143+
Assert.True(set.TryAdd(i));
144+
145+
Assert.Equal(10, _hashCallCount);
146+
Assert.Equal(10, set.Count);
147+
}
148+
149+
[Fact]
150+
public void IntSet_TryAdd_DuplicateItem_DoesExactlyOneProbeWalk()
151+
{
152+
var set = new IntSet<CountingIntHasher>(capacity: 64);
153+
for (int i = 1; i <= 5; i++)
154+
set.TryAdd(i);
155+
156+
_hashCallCount = 0;
157+
for (int i = 1; i <= 5; i++)
158+
Assert.False(set.TryAdd(i));
159+
160+
Assert.Equal(5, _hashCallCount);
161+
Assert.Equal(5, set.Count);
162+
}
163+
164+
[Fact]
165+
public void CeleritySet_TryAdd_NewItem_DoesExactlyOneProbeWalk()
166+
{
167+
var set = new CeleritySet<string, CountingStringHasher>(capacity: 64);
168+
_hashCallCount = 0;
169+
170+
for (int i = 1; i <= 10; i++)
171+
Assert.True(set.TryAdd($"v{i}"));
172+
173+
Assert.Equal(10, _hashCallCount);
174+
Assert.Equal(10, set.Count);
175+
}
176+
177+
[Fact]
178+
public void CeleritySet_TryAdd_DuplicateItem_DoesExactlyOneProbeWalk()
179+
{
180+
var set = new CeleritySet<string, CountingStringHasher>(capacity: 64);
181+
for (int i = 1; i <= 5; i++)
182+
set.TryAdd($"v{i}");
183+
184+
_hashCallCount = 0;
185+
for (int i = 1; i <= 5; i++)
186+
Assert.False(set.TryAdd($"v{i}"));
187+
188+
Assert.Equal(5, _hashCallCount);
189+
Assert.Equal(5, set.Count);
190+
}
191+
192+
[Fact]
193+
public void IntDictionary_TryAdd_PreservesExistingValueOnDuplicate()
194+
{
195+
// Behavioural guard: TryAdd must not overwrite the existing value when
196+
// it returns false. The single-probe rewrite has to be careful here
197+
// because ProbeForInsert returns the existing slot — we must read,
198+
// detect, and bail out before writing.
199+
var map = new IntDictionary<int, CountingIntHasher>(capacity: 64);
200+
map.TryAdd(7, 700);
201+
202+
Assert.False(map.TryAdd(7, -1));
203+
Assert.Equal(700, map[7]);
204+
}
205+
206+
[Fact]
207+
public void CelerityDictionary_TryAdd_PreservesExistingValueOnDuplicate()
208+
{
209+
var map = new CelerityDictionary<string, int, CountingStringHasher>(capacity: 64);
210+
map.TryAdd("k7", 700);
211+
212+
Assert.False(map.TryAdd("k7", -1));
213+
Assert.Equal(700, map["k7"]);
214+
}
215+
}

src/Celerity/Collections/CelerityDictionary.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,22 @@ public bool TryAdd(TKey key, TValue value)
290290
return true;
291291
}
292292

293-
if (ContainsKey(key))
293+
// Single probe: ProbeForInsert returns either the slot of an existing
294+
// entry or the first empty slot in the chain. If it's the former, the
295+
// key already exists; otherwise we insert here directly. This avoids
296+
// the double probe-chain walk that `if (ContainsKey(key)) ...; this[key] = value;`
297+
// would do.
298+
if (_count >= _threshold)
299+
Resize();
300+
301+
int index = ProbeForInsert(key);
302+
if (!EqualityComparer<TKey>.Default.Equals(_keys[index], default(TKey)))
294303
return false;
295304

296-
this[key] = value;
305+
_keys[index] = key;
306+
_values[index] = value;
307+
_count++;
308+
_version++;
297309
return true;
298310
}
299311

src/Celerity/Collections/CeleritySet.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,24 @@ public bool TryAdd(T item)
100100
return true;
101101
}
102102

103-
if (Contains(item))
104-
return false;
103+
// Single probe: walk the probe chain once and either spot the existing
104+
// entry (return false) or land on an empty slot and insert in place.
105+
// Avoids the double walk of `if (Contains(item)) ...; InsertNonDefault(item);`.
106+
if (_count >= _threshold)
107+
Resize();
108+
109+
int size = _slots.Length;
110+
int index = _hasher.Hash(item) & (size - 1);
111+
112+
while (!EqualityComparer<T>.Default.Equals(_slots[index], default(T)))
113+
{
114+
if (EqualityComparer<T>.Default.Equals(_slots[index], item))
115+
return false;
116+
index = (index + 1) & (size - 1);
117+
}
105118

106-
InsertNonDefault(item);
119+
_slots[index] = item;
120+
_count++;
107121
return true;
108122
}
109123

src/Celerity/Collections/IntDictionary.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,22 @@ public bool TryAdd(int key, TValue value)
345345
return true;
346346
}
347347

348-
if (ContainsKey(key))
348+
// Single probe: ProbeForInsert returns either the slot of an existing
349+
// entry or the first empty slot in the chain. If it's the former, the
350+
// key already exists; otherwise we insert here directly. This avoids
351+
// the double probe-chain walk that `if (ContainsKey(key)) ...; this[key] = value;`
352+
// would do.
353+
if (_count >= _threshold)
354+
Resize();
355+
356+
int index = ProbeForInsert(key);
357+
if (_keys[index] != EMPTY_KEY)
349358
return false;
350359

351-
this[key] = value;
360+
_keys[index] = key;
361+
_values[index] = value;
362+
_count++;
363+
_version++;
352364
return true;
353365
}
354366

src/Celerity/Collections/IntSet.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,24 @@ public bool TryAdd(int item)
124124
return true;
125125
}
126126

127-
if (Contains(item))
128-
return false;
127+
// Single probe: walk the probe chain once and either spot the existing
128+
// entry (return false) or land on an empty slot and insert in place.
129+
// Avoids the double walk of `if (Contains(item)) ...; InsertNonZero(item);`.
130+
if (_count >= _threshold)
131+
Resize();
132+
133+
int size = _slots.Length;
134+
int index = _hasher.Hash(item) & (size - 1);
135+
136+
while (_slots[index] != EMPTY_SLOT)
137+
{
138+
if (_slots[index] == item)
139+
return false;
140+
index = (index + 1) & (size - 1);
141+
}
129142

130-
InsertNonZero(item);
143+
_slots[index] = item;
144+
_count++;
131145
return true;
132146
}
133147

0 commit comments

Comments
 (0)