diff --git a/src/SharpArena/Collections/ArenaDictionary.cs b/src/SharpArena/Collections/ArenaDictionary.cs index 80e01fd..4d914ca 100644 --- a/src/SharpArena/Collections/ArenaDictionary.cs +++ b/src/SharpArena/Collections/ArenaDictionary.cs @@ -1,4 +1,6 @@ using System.Collections; +using System.Buffers; +using System.Text; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -242,29 +244,125 @@ public bool ContainsKey(TKey key) } /// - /// Specialized ContainsKey for ArenaUtf16String using ReadOnlySpan{char} to avoid allocations. + /// Specialized ContainsKey for string types using ReadOnlySpan{char} to avoid allocations. + /// Supports cross-encoding for ArenaUtf8String. /// /// The key to locate in the . /// if the contains an element with the key; otherwise, . public bool ContainsKey(ReadOnlySpan key) { - if (typeof(TKey) != typeof(ArenaUtf16String)) return false; - CheckAlive(); + if (typeof(TKey) == typeof(ArenaUtf16String)) + { + CheckAlive(); + + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys; + uint mask = capacity - 1; + uint hash = Hashing.HashString(key); + uint index = hash & mask; + + while (true) + { + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) return false; + if (keys[entryIdxPlusOne - 1].Equals(key)) return true; + index = (index + 1) & mask; + } + } - uint capacity = (uint)_header->Capacity; - int* buckets = _header->Buckets; - ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys; - uint mask = capacity - 1; - uint hash = Hashing.HashString(key); - uint index = hash & mask; + if (typeof(TKey) == typeof(ArenaUtf8String)) + { + CheckAlive(); + int maxBytes = Encoding.UTF8.GetMaxByteCount(key.Length); + byte[]? rented = null; + Span buffer = maxBytes <= 512 ? stackalloc byte[512] : (rented = ArrayPool.Shared.Rent(maxBytes)); + try + { + int written = Encoding.UTF8.GetBytes(key, buffer); + ReadOnlySpan byteKey = buffer.Slice(0, written); + + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys; + uint mask = capacity - 1; + uint hash = Hashing.HashUtf8(byteKey); + uint index = hash & mask; + while (true) + { + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) return false; + if (keys[entryIdxPlusOne - 1].Equals(byteKey)) return true; + index = (index + 1) & mask; + } + } + finally + { + if (rented != null) ArrayPool.Shared.Return(rented); + } + } - while (true) + return false; + } + + /// + /// Specialized ContainsKey for ArenaUtf8String using ReadOnlySpan{byte} to avoid allocations. + /// + /// The key to locate in the . + /// if the contains an element with the key; otherwise, . + public bool ContainsKey(ReadOnlySpan key) + { + if (typeof(TKey) == typeof(ArenaUtf8String)) { - int entryIdxPlusOne = buckets[index]; - if (entryIdxPlusOne == 0) return false; - if (keys[entryIdxPlusOne - 1].Equals(key)) return true; - index = (index + 1) & mask; + CheckAlive(); + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys; + uint mask = capacity - 1; + uint hash = Hashing.HashUtf8(key); + uint index = hash & mask; + while (true) + { + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) return false; + if (keys[entryIdxPlusOne - 1].Equals(key)) return true; + index = (index + 1) & mask; + } } + + if (typeof(TKey) == typeof(ArenaUtf16String)) + { + CheckAlive(); + int maxChars = Encoding.UTF8.GetMaxCharCount(key.Length); + char[]? rented = null; + Span buffer = maxChars <= 512 ? stackalloc char[512] : (rented = ArrayPool.Shared.Rent(maxChars)); + try + { + int written = Encoding.UTF8.GetChars(key, buffer); + ReadOnlySpan charKey = buffer.Slice(0, written); + + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys; + uint mask = capacity - 1; + uint hash = Hashing.HashString(charKey); + uint index = hash & mask; + + while (true) + { + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) return false; + if (keys[entryIdxPlusOne - 1].Equals(charKey)) return true; + index = (index + 1) & mask; + } + } + finally + { + if (rented != null) ArrayPool.Shared.Return(rented); + } + } + + return false; } /// @@ -288,38 +386,145 @@ public bool TryGetValue(TKey key, out TValue value) } /// - /// Specialized TryGetValue for ArenaUtf16String using ReadOnlySpan{char} to avoid allocations. + /// Specialized TryGetValue for string types using ReadOnlySpan{char} to avoid allocations. + /// Supports cross-encoding for ArenaUtf8String. /// /// The key whose value to get. /// When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. /// if the contains an element with the specified key; otherwise, . public bool TryGetValue(ReadOnlySpan key, out TValue value) { - if (typeof(TKey) != typeof(ArenaUtf16String)) + if (typeof(TKey) == typeof(ArenaUtf16String)) { - value = default; - return false; + CheckAlive(); + + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys; + TValue* values = (TValue*)_header->Values; + uint mask = capacity - 1; + uint hash = Hashing.HashString(key); + uint index = hash & mask; + + while (true) + { + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) break; + if (keys[entryIdxPlusOne - 1].Equals(key)) + { + value = values[entryIdxPlusOne - 1]; + return true; + } + index = (index + 1) & mask; + } } - CheckAlive(); - uint capacity = (uint)_header->Capacity; - int* buckets = _header->Buckets; - ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys; - TValue* values = (TValue*)_header->Values; - uint mask = capacity - 1; - uint hash = Hashing.HashString(key); - uint index = hash & mask; + if (typeof(TKey) == typeof(ArenaUtf8String)) + { + CheckAlive(); + int maxBytes = Encoding.UTF8.GetMaxByteCount(key.Length); + byte[]? rented = null; + Span buffer = maxBytes <= 512 ? stackalloc byte[512] : (rented = ArrayPool.Shared.Rent(maxBytes)); + try + { + int written = Encoding.UTF8.GetBytes(key, buffer); + ReadOnlySpan byteKey = buffer.Slice(0, written); + + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys; + TValue* values = (TValue*)_header->Values; + uint mask = capacity - 1; + uint hash = Hashing.HashUtf8(byteKey); + uint index = hash & mask; + while (true) + { + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) break; + if (keys[entryIdxPlusOne - 1].Equals(byteKey)) + { + value = values[entryIdxPlusOne - 1]; + return true; + } + index = (index + 1) & mask; + } + } + finally + { + if (rented != null) ArrayPool.Shared.Return(rented); + } + } - while (true) + value = default; + return false; + } + + /// + /// Specialized TryGetValue for ArenaUtf8String using ReadOnlySpan{byte} to avoid allocations. + /// + /// The key whose value to get. + /// When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. + /// if the contains an element with the specified key; otherwise, . + public bool TryGetValue(ReadOnlySpan key, out TValue value) + { + if (typeof(TKey) == typeof(ArenaUtf8String)) { - int entryIdxPlusOne = buckets[index]; - if (entryIdxPlusOne == 0) break; - if (keys[entryIdxPlusOne - 1].Equals(key)) + CheckAlive(); + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf8String* keys = (ArenaUtf8String*)_header->Keys; + TValue* values = (TValue*)_header->Values; + uint mask = capacity - 1; + uint hash = Hashing.HashUtf8(key); + uint index = hash & mask; + while (true) { - value = values[entryIdxPlusOne - 1]; - return true; + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) break; + if (keys[entryIdxPlusOne - 1].Equals(key)) + { + value = values[entryIdxPlusOne - 1]; + return true; + } + index = (index + 1) & mask; + } + } + + if (typeof(TKey) == typeof(ArenaUtf16String)) + { + CheckAlive(); + int maxChars = Encoding.UTF8.GetMaxCharCount(key.Length); + char[]? rented = null; + Span buffer = maxChars <= 512 ? stackalloc char[512] : (rented = ArrayPool.Shared.Rent(maxChars)); + try + { + int written = Encoding.UTF8.GetChars(key, buffer); + ReadOnlySpan charKey = buffer.Slice(0, written); + + uint capacity = (uint)_header->Capacity; + int* buckets = _header->Buckets; + ArenaUtf16String* keys = (ArenaUtf16String*)_header->Keys; + TValue* values = (TValue*)_header->Values; + uint mask = capacity - 1; + uint hash = Hashing.HashString(charKey); + uint index = hash & mask; + + while (true) + { + int entryIdxPlusOne = buckets[index]; + if (entryIdxPlusOne == 0) break; + if (keys[entryIdxPlusOne - 1].Equals(charKey)) + { + value = values[entryIdxPlusOne - 1]; + return true; + } + index = (index + 1) & mask; + } + } + finally + { + if (rented != null) ArrayPool.Shared.Return(rented); } - index = (index + 1) & mask; } value = default; diff --git a/src/SharpArena/Collections/Hashing.cs b/src/SharpArena/Collections/Hashing.cs index af5070a..15cf4b7 100644 --- a/src/SharpArena/Collections/Hashing.cs +++ b/src/SharpArena/Collections/Hashing.cs @@ -18,8 +18,7 @@ public static uint Hash(T value) where T : unmanaged return (uint)Unsafe.As(ref value).GetHashCode(); } - var span = new ReadOnlySpan(&value, sizeof(T)); - return (uint)XxHash3.HashToUInt64(span); + return (uint)EqualityComparer.Default.GetHashCode(value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs b/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs index e688905..19ea570 100644 --- a/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs +++ b/tests/SharpArena.Tests/Collections/ArenaDictionaryTests.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; +using System.Text; using FluentAssertions; using SharpArena.Allocators; using SharpArena.Collections; @@ -5,12 +7,31 @@ namespace SharpArena.Tests.Collections; +[StructLayout(LayoutKind.Sequential)] +public struct PaddedKey : IEquatable +{ + public byte A; + public int B; // padding exists between A and B + public bool Equals(PaddedKey other) => A == other.A && B == other.B; + public override int GetHashCode() => HashCode.Combine(A, B); +} + public class ArenaDictionaryTests : IDisposable { private readonly ArenaAllocator _arena = new(); public void Dispose() => _arena.Dispose(); + [Fact] + public void PaddedStructHashing_IgnoresPadding() + { + var dict = new ArenaDictionary(_arena); + + var key1 = new PaddedKey { A = 1, B = 2 }; + dict.Add(key1, 42); + dict.ContainsKey(key1).Should().BeTrue(); + } + [Fact] public void Add_NewEntry_IncrementsCountAndEnablesLookup() { @@ -110,6 +131,131 @@ public void ArenaUtf16String_AsKey_WorksCorrectly() dict.ContainsKey(ArenaUtf16String.Clone("key3", _arena)).Should().BeFalse(); } + [Fact] + public void ContainsKey_Utf8ByteSpan_Works() + { + var dict = new ArenaDictionary(_arena); + var key = ArenaUtf8String.Clone("test", _arena); + dict.Add(key, 123); + + ReadOnlySpan query = "test"u8; + dict.ContainsKey(query).Should().BeTrue(); + } + + [Fact] + public void TryGetValue_Utf8ByteSpan_Works() + { + var dict = new ArenaDictionary(_arena); + var key = ArenaUtf8String.Clone("test", _arena); + dict.Add(key, 123); + + ReadOnlySpan query = "test"u8; + dict.TryGetValue(query, out var val).Should().BeTrue(); + val.Should().Be(123); + } + + [Fact] + public void ContainsKey_CharSpan_OnUtf8Dict_Works() + { + var dict = new ArenaDictionary(_arena); + dict.Add(ArenaUtf8String.Clone("hello", _arena), 99); + + dict.ContainsKey("hello".AsSpan()).Should().BeTrue(); + } + + [Fact] + public void TryGetValue_CharSpan_OnUtf8Dict_Works() + { + var dict = new ArenaDictionary(_arena); + dict.Add(ArenaUtf8String.Clone("hello", _arena), 99); + + dict.TryGetValue("hello".AsSpan(), out var val).Should().BeTrue(); + val.Should().Be(99); + } + + [Fact] + public void TryGetValue_LargeCharSpan_OnUtf8Dict_Works() + { + var dict = new ArenaDictionary(_arena); + string largeKey = new string('a', 600); + dict.Add(ArenaUtf8String.Clone(largeKey, _arena), 1234); + + dict.TryGetValue(largeKey.AsSpan(), out var val).Should().BeTrue(); + val.Should().Be(1234); + } + + [Fact] + public void ContainsKey_ByteSpan_OnUtf16Dict_Works() + { + var dict = new ArenaDictionary(_arena); + dict.Add(ArenaUtf16String.Clone("world", _arena), 77); + + ReadOnlySpan query = "world"u8; + dict.ContainsKey(query).Should().BeTrue(); + } + + [Fact] + public void TryGetValue_ByteSpan_OnUtf16Dict_Works() + { + var dict = new ArenaDictionary(_arena); + dict.Add(ArenaUtf16String.Clone("world", _arena), 77); + + ReadOnlySpan query = "world"u8; + dict.TryGetValue(query, out var val).Should().BeTrue(); + val.Should().Be(77); + } + + [Fact] + public void TryGetValue_LargeByteSpan_OnUtf16Dict_Works() + { + var dict = new ArenaDictionary(_arena); + string largeKey = new string('b', 600); + dict.Add(ArenaUtf16String.Clone(largeKey, _arena), 5678); + + ReadOnlySpan query = Encoding.UTF8.GetBytes(largeKey); + dict.TryGetValue(query, out var val).Should().BeTrue(); + val.Should().Be(5678); + } + + [Fact] + public void ContainsKey_LargeByteSpan_OnUtf16Dict_Works() + { + var dict = new ArenaDictionary(_arena); + string largeKey = new string('c', 600); + dict.Add(ArenaUtf16String.Clone(largeKey, _arena), 999); + + ReadOnlySpan query = Encoding.UTF8.GetBytes(largeKey); + dict.ContainsKey(query).Should().BeTrue(); + } + + [Fact] + public void StressTest_CrossEncoding_LargeStrings() + { + var dictUtf8 = new ArenaDictionary(_arena); + var dictUtf16 = new ArenaDictionary(_arena); + + for (int i = 0; i < 100; i++) + { + string key = new string((char)('a' + (i % 26)), 513 + i); + dictUtf8.Add(ArenaUtf8String.Clone(key, _arena), i); + dictUtf16.Add(ArenaUtf16String.Clone(key, _arena), i); + } + + for (int i = 0; i < 100; i++) + { + string key = new string((char)('a' + (i % 26)), 513 + i); + + // Char -> Utf8 + dictUtf8.TryGetValue(key.AsSpan(), out var val1).Should().BeTrue(); + val1.Should().Be(i); + + // Byte -> Utf16 + ReadOnlySpan keyBytes = Encoding.UTF8.GetBytes(key); + dictUtf16.TryGetValue(keyBytes, out var val2).Should().BeTrue(); + val2.Should().Be(i); + } + } + [Fact] public void Clear_ResetsCountAndLookups() {