diff --git a/Unhinged.Playground/Program.cs b/Unhinged.Playground/Program.cs index 3692149..3d80591 100644 --- a/Unhinged.Playground/Program.cs +++ b/Unhinged.Playground/Program.cs @@ -8,6 +8,8 @@ #pragma warning disable CA2014 +// dotnet publish -c Release /p:PublishAot=true /p:OptimizationPreference=Speed + [SkipLocalsInit] internal static class Program { @@ -15,7 +17,7 @@ public static void Main(string[] args) { var builder = UnhingedEngine .CreateBuilder() - .SetNWorkersSolver(() => Environment.ProcessorCount / 2) + .SetNWorkersSolver(() => (Environment.ProcessorCount / 2) - 2) .SetBacklog(16384) .SetMaxEventsPerWake(512) .SetMaxNumberConnectionsPerWorker(512) @@ -28,13 +30,25 @@ public static void Main(string[] args) engine.Run(); } - private static void RequestHandler(Connection connection) + private static ValueTask RequestHandler(Connection connection) { - if(connection.HashedRoute == 291830056) // /json + /*if(connection.HashedRoute == 291830056) // /json CommitJsonResponse(connection); else if (connection.HashedRoute == 3454831873) // /plaintext + CommitPlainTextResponse(connection);*/ + + if (connection.H1HeaderData.Route.Equals("/json")) + { + CommitJsonResponse(connection); + } + + else if (connection.H1HeaderData.Route.Equals("/plaintext")) + { CommitPlainTextResponse(connection); + } + + return ValueTask.CompletedTask; } [ThreadStatic] private static Utf8JsonWriter? _tUtf8JsonWriter; diff --git a/Unhinged/ABI/Native.cs b/Unhinged/ABI/Native.cs index f990d13..421c517 100644 --- a/Unhinged/ABI/Native.cs +++ b/Unhinged/ABI/Native.cs @@ -144,9 +144,12 @@ internal static unsafe class Native /// [DllImport("libc", SetLastError = true)] internal static extern int eventfd(uint initval, int flags); - [DllImport("libc", SetLastError = true)] internal static extern int sched_setaffinity(int pid, nuint cpusetsize, ref ulong mask); - - + [DllImport("libc", SetLastError = true)] internal static extern int sched_setaffinity(int pid, IntPtr cpusetsize, ref ulong mask); + + [DllImport("libc", SetLastError = true)] internal static extern int sched_setaffinity(int pid, IntPtr cpusetsize, ref cpu_set_t mask); + + [DllImport("libc")] internal static extern int gettid(); // Linux thread id + // ========================= // Struct definitions // ========================= @@ -264,4 +267,28 @@ internal struct Linger internal const int EPIPE = 32; internal const int ECONNABORTED = 103; internal const int ECONNRESET = 104; + + public static void PinCurrentThreadToCpu(int cpuIndex) + { + if (cpuIndex < 0 || cpuIndex >= Environment.ProcessorCount) + throw new ArgumentOutOfRangeException(nameof(cpuIndex)); + + unsafe + { + var set = new cpu_set_t(); + int word = cpuIndex / 64; + int bit = cpuIndex % 64; + set.Bits[word] = 1UL << bit; + + int tid = gettid(); + int ret = sched_setaffinity(tid, (IntPtr)sizeof(cpu_set_t), ref set); + if (ret != 0) + throw new InvalidOperationException($"sched_setaffinity failed with errno {Marshal.GetLastPInvokeError()}"); + } + } } + +internal unsafe struct cpu_set_t +{ + public fixed ulong Bits[16]; // 1024 bits (enough for up to 1024 CPUs) +} \ No newline at end of file diff --git a/Unhinged/Engine/Connection.cs b/Unhinged/Engine/Connection.cs index a8502d0..0f62317 100644 --- a/Unhinged/Engine/Connection.cs +++ b/Unhinged/Engine/Connection.cs @@ -24,8 +24,11 @@ public unsafe class Connection : IDisposable /// Writer over the send slab. public readonly FixedBufferWriter WriteBuffer; - // Fnv1a32 hashed route - public uint HashedRoute { get; set; } + /// + /// Header data, no allocations + /// + public BinaryH1HeaderData BinaryH1HeaderData { get; set; } + public H1HeaderData H1HeaderData { get; set; } /// Used to size the slabs (typically per-worker slab size). /// Bytes per connection for receive. @@ -40,6 +43,11 @@ public Connection(int maxConnections, int inSlabSize, int outSlabSize) outSlabSize); } + public void Clear() + { + H1HeaderData?.Clear(); + } + /// /// Frees the unmanaged slabs. Call exactly once when the connection is permanently done. /// diff --git a/Unhinged/Engine/UnhingedEngine.Builder.cs b/Unhinged/Engine/UnhingedEngine.Builder.cs index a1658c6..e7c1d62 100644 --- a/Unhinged/Engine/UnhingedEngine.Builder.cs +++ b/Unhinged/Engine/UnhingedEngine.Builder.cs @@ -42,15 +42,17 @@ public sealed partial class UnhingedEngine private static Func? _calculateNumberWorkers; // Default request handler (overridden via builder). Writes a minimal plaintext response. - private static Action _sRequestHandler = DefaultRequestHandler; + private static Func _sRequestHandler = DefaultRequestHandler; - private static void DefaultRequestHandler(Connection connection) + private static ValueTask DefaultRequestHandler(Connection connection) { connection.WriteBuffer.WriteUnmanaged("HTTP/1.1 200 OK\r\n"u8 + "Server: W\r\n"u8 + "Content-Type: text/plain\r\n"u8 + "Content-Length: 28\r\n\r\n"u8 + "Request handler was not set!"u8 ); + + return ValueTask.CompletedTask; } private UnhingedEngine() { } @@ -134,7 +136,7 @@ public UnhingedBuilder SetNWorkersSolver(Func? solver) /// /// Inject the request handler used by workers to serve requests. /// - public UnhingedBuilder InjectRequestHandler(Action requestHandler) + public UnhingedBuilder InjectRequestHandler(Func requestHandler) { _sRequestHandler = requestHandler; return this; diff --git a/Unhinged/Engine/UnhingedEngine.Runner.cs b/Unhinged/Engine/UnhingedEngine.Runner.cs index 22eb02b..9d6dc39 100644 --- a/Unhinged/Engine/UnhingedEngine.Runner.cs +++ b/Unhinged/Engine/UnhingedEngine.Runner.cs @@ -41,7 +41,14 @@ public void Run() // - IsBackground=true so the process can exit if only workers remain. // - Name aids debugging and logs. // - Stack size is configurable to accommodate stackalloc-heavy hot paths. - var t = new Thread(() => WorkerLoop(W[iCap]), _maxStackSizePerThread) // 1MB + var t = new Thread(() => + { + // Performance seems to be lower when pinning threads to cpu core + //PinCurrentThreadToCpu(iCap); + //Console.WriteLine($"Thread {iCap} pinned to CPU {iCap}"); + + WorkerLoop(W[iCap]); + }, _maxStackSizePerThread) // 1MB { IsBackground = true, Name = $"worker-{iCap}" diff --git a/Unhinged/Engine/UnhingedEngine.Worker.cs b/Unhinged/Engine/UnhingedEngine.Worker.cs index a1661af..855b7d4 100644 --- a/Unhinged/Engine/UnhingedEngine.Worker.cs +++ b/Unhinged/Engine/UnhingedEngine.Worker.cs @@ -4,11 +4,13 @@ // ReSharper disable always StackAllocInsideLoop // ReSharper disable always ClassCannotBeInstantiated +using System.Text; + #pragma warning disable CA2014 namespace Unhinged; -public sealed unsafe partial class UnhingedEngine +public sealed partial class UnhingedEngine { /// /// Result code for attempts to flush the connection's write buffer. @@ -35,9 +37,11 @@ private class ConnectionPoolPolicy : PooledObjectPolicy /// /// Return a Connection to the pool. Consider resetting/clearing per-request state here. /// - public override bool Return(Connection context) + public override bool Return(Connection connection) { // Potentially reset buffers here (e.g., context.Reset()) to avoid data leaks across usages. + connection.Clear(); + return true; } } @@ -56,7 +60,7 @@ public override bool Return(Connection context) /// 4) Handle EPOLLOUT (write-ready) to continue flushing responses. /// 5) On error/hup, close and recycle the connection. /// - private static void WorkerLoop(Worker W) + private static unsafe void WorkerLoop(Worker W) { // Per-worker connection table var connections = new Dictionary(capacity: _maxNumberConnectionsPerWorker); @@ -134,8 +138,11 @@ private static void WorkerLoop(Worker W) if (got > 0) { c.Tail += (int)got; - continue; + // TODO: Which one, continue or break? break avoids an extra read to get a EAGAIN + // TODO: But continue may read more data without the need of an extra epoll event + //continue; + break; } if (got == 0) { CloseConn(fd, connections, W); break; } // peer closed @@ -153,7 +160,32 @@ private static void WorkerLoop(Worker W) CloseConn(fd, connections, W); break; // default: close on unexpected errors } - var dataToBeFlushedAvailable = TryParseRequests(c); + _ = TryParseRequests(c, fd, W, connections); + + /*if (task is { IsCompleted: true, Result: true }) + { + var tryEmptyResult = TryEmptyWriteBuffer(c, ref fd); + if (tryEmptyResult == EmptyAttemptResult.Complete) // Hot path + { + // All requests were flushed, stay EPOLLIN + //ArmEpollIn(ref fd, W.Ep); + // Move on to the next event + continue; + } + if (tryEmptyResult == EmptyAttemptResult.Incomplete) + { + // There is still data to be flushed in the buffer, arm EPOLLOUT + ArmEpollOut(ref fd, W.Ep); + continue; + } + if (tryEmptyResult == EmptyAttemptResult.CloseConnection) + { + CloseConn(fd, connections, W); + continue; + } + }*/ + + /*var dataToBeFlushedAvailable = TryParseRequests(c); if (dataToBeFlushedAvailable) { var tryEmptyResult = TryEmptyWriteBuffer(c, ref fd); @@ -174,7 +206,7 @@ private static void WorkerLoop(Worker W) CloseConn(fd, connections, W); continue; } - } + }*/ // Move on to the next event... continue; @@ -216,23 +248,107 @@ private static void WorkerLoop(Worker W) /// The receive window is compacted when partial data remains. /// Returns true if any response data was staged and should be flushed. /// - private static bool TryParseRequests(Connection connection) + private static async ValueTask TryParseRequests( + Connection connection, + int fd, + Worker W, + Dictionary connections) { bool hasDataToFlush = false; + int idx = 0; + + while (true) + { + unsafe + { + // Try getting a full request header, if unsuccessful signal caller more data is needed + var headerSpan = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail, ref idx); + if (idx < 0) + break; + + // A full request was received, handle it + + // Extract the request Header data + connection.H1HeaderData = ExtractH1HeaderData(headerSpan); + + // Advance the pointer after the request was dealt with + connection.Head = idx + 4; // advance past CRLFCRLF + } + + await _sRequestHandler(connection); + + // Clear pooled dictionaries (query parameters + headers) + connection.Clear(); + + // Mark that there is data to flush (a request was fully processed) + hasDataToFlush = true; + + if (connection.Head == connection.Tail) // No more data to read + break; + } + + unsafe + { + // If there is unprocessed data in the receiving buffer (incomplete request) which is not at buffer start + // Move the incomplete request to the buffer start and reset head and tail to 0 + if (connection.Head > 0 && connection.Head < connection.Tail) + { + Buffer.MemoryCopy( + connection.ReceiveBuffer + connection.Head, + connection.ReceiveBuffer, + _inSlabSize, + connection.Tail - connection.Head); + } + } + + //Reset the receiving buffer + connection.Head = connection.Tail = 0; + + if (hasDataToFlush) + { + var tryEmptyResult = TryEmptyWriteBuffer(connection, ref fd); + if (tryEmptyResult == EmptyAttemptResult.Complete) // Hot path + { + // All requests were flushed, stay EPOLLIN + return; + } + if (tryEmptyResult == EmptyAttemptResult.Incomplete) + { + // There is still data to be flushed in the buffer, arm EPOLLOUT + ArmEpollOut(ref fd, W.Ep); + return; + } + if (tryEmptyResult == EmptyAttemptResult.CloseConnection) + { + CloseConn(fd, connections, W); + return; + } + } + + } + + private static unsafe bool TryParseRequests(Connection connection) + { + bool hasDataToFlush = false; + + int idx = 0; + while (true) { // Try getting a full request header, if unsuccessful signal caller more data is needed //int idx = FindCrlfCrlf(connection.Buf, connection.Head, connection.Tail); //int idx = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail); - var headerSpan = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail, out int idx); + var headerSpan = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail, ref idx); if (idx < 0) break; - + + // A full request was received, handle it - + // Extract the route - connection.HashedRoute = ExtractRoute(headerSpan); + connection.H1HeaderData = ExtractH1HeaderData(headerSpan); + _sRequestHandler(connection); // Advance the pointer after the request was dealt with @@ -250,14 +366,15 @@ private static bool TryParseRequests(Connection connection) if (connection.Head > 0 && connection.Head < connection.Tail) { Buffer.MemoryCopy( - connection.ReceiveBuffer + connection.Head, - connection.ReceiveBuffer, - _inSlabSize, + connection.ReceiveBuffer + connection.Head, + connection.ReceiveBuffer, + _inSlabSize, connection.Tail - connection.Head); } - + //Reset the receiving buffer connection.Head = connection.Tail = 0; + return hasDataToFlush; } @@ -268,7 +385,7 @@ private static bool TryParseRequests(Connection connection) /// - Incomplete: partial write or EAGAIN; caller should arm EPOLLOUT /// - CloseConnection: hard error while sending; caller should close the fd /// - private static EmptyAttemptResult TryEmptyWriteBuffer(Connection connection, ref int fd) + private static unsafe EmptyAttemptResult TryEmptyWriteBuffer(Connection connection, ref int fd) { while (true) { @@ -321,7 +438,7 @@ private static EmptyAttemptResult TryEmptyWriteBuffer(Connection connection, ref /// Arms EPOLLIN on the given fd (while preserving error/hup interest). /// Use after fully draining the write buffer to resume read-driven operation. /// - private static void ArmEpollIn(ref int fd, int ep) + private static unsafe void ArmEpollIn(ref int fd, int ep) { byte* ev = stackalloc byte[EvSize]; WriteEpollEvent(ev, EPOLLIN | EPOLLRDHUP | EPOLLERR | EPOLLHUP, fd); @@ -332,7 +449,7 @@ private static void ArmEpollIn(ref int fd, int ep) /// Arms EPOLLOUT on the given fd (while preserving error/hup interest). /// Use when a partial flush occurs and more write-ready notifications are required. /// - private static void ArmEpollOut(ref int fd, int ep) + private static unsafe void ArmEpollOut(ref int fd, int ep) { byte* ev = stackalloc byte[EvSize]; WriteEpollEvent(ev, EPOLLOUT | EPOLLRDHUP | EPOLLERR | EPOLLHUP, fd); @@ -364,4 +481,23 @@ private static void CloseConn(int fd, Dictionary map, Worker W) /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void CloseQuiet(int fd) { try { close(fd); } catch { } } -} \ No newline at end of file +} + +// Some stuff that may be useful if I need to always schedule a task on the threadpool + +//_ = Task.Run(() => TryParseRequests(c, fd, W, connections)); +/*ThreadPool.UnsafeQueueUserWorkItem(static state => +{ + var (conn, fd, w, dict) = + ((Connection,int,Worker,Dictionary))state!; + + var t = TryParseRequests(conn, fd, w, dict).AsTask(); + + if (!t.IsCompletedSuccessfully) + { + _ = t.ContinueWith( + tt => Console.WriteLine(tt.Exception!), // your logging + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously); + } +}, (c, fd, W, connections));*/ \ No newline at end of file diff --git a/Unhinged/HttpProtocol/H1/BinaryH1HeaderData.cs b/Unhinged/HttpProtocol/H1/BinaryH1HeaderData.cs new file mode 100644 index 0000000..acecc96 --- /dev/null +++ b/Unhinged/HttpProtocol/H1/BinaryH1HeaderData.cs @@ -0,0 +1,14 @@ +namespace Unhinged; + +public struct BinaryH1HeaderData +{ + public PinnedByteSequence HttpMethod; + + public PinnedByteSequence Route; + + public PinnedByteSequence QueryParameters; + + public bool HasQueryParameters => QueryParameters.Length > 0; + + public PinnedByteSequence Headers; +} \ No newline at end of file diff --git a/Unhinged/HttpProtocol/H1/CachedH1Data.cs b/Unhinged/HttpProtocol/H1/CachedH1Data.cs new file mode 100644 index 0000000..de3edd4 --- /dev/null +++ b/Unhinged/HttpProtocol/H1/CachedH1Data.cs @@ -0,0 +1,38 @@ +namespace Unhinged; + +internal static class CachedH1Data +{ + internal static readonly StringCache CachedRoutes + = new(null, 64); + + internal static readonly StringCache CachedQueryKeys + = new(null, 64); + + internal static readonly StringCache CachedHttpMethods + = new([ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + "TRACE"], + 8); + + internal static readonly StringCache CachedHeaderKeys + = new([ + "Host", + "User-Agent", + "Cookie", + "Accept", + "Accept-Language", + "Connection"], + 64); + + internal static readonly StringCache CachedHeaderValues + = new([ + "keep-alive", + "server"], + 64); +} \ No newline at end of file diff --git a/Unhinged/HttpProtocol/H1/H1HeaderData.cs b/Unhinged/HttpProtocol/H1/H1HeaderData.cs new file mode 100644 index 0000000..018868f --- /dev/null +++ b/Unhinged/HttpProtocol/H1/H1HeaderData.cs @@ -0,0 +1,26 @@ +namespace Unhinged; + +public class H1HeaderData +{ + public string Route { get; internal set; } = null!; + public string HttpMethod { get; internal set; } = null!; + public PooledDictionary QueryParameters { get; } + public PooledDictionary Headers { get; } + + public H1HeaderData() + { + QueryParameters = new PooledDictionary( + capacity: 8, + comparer: StringComparer.OrdinalIgnoreCase); + + Headers = new PooledDictionary( + capacity: 8, + comparer: StringComparer.OrdinalIgnoreCase); + } + + public void Clear() + { + Headers?.Clear(); + QueryParameters?.Clear(); + } +} \ No newline at end of file diff --git a/Unhinged/HttpProtocol/HeaderParsing.cs b/Unhinged/HttpProtocol/H1/HeaderParsing.cs similarity index 51% rename from Unhinged/HttpProtocol/HeaderParsing.cs rename to Unhinged/HttpProtocol/H1/HeaderParsing.cs index 30c914f..6972c4c 100644 --- a/Unhinged/HttpProtocol/HeaderParsing.cs +++ b/Unhinged/HttpProtocol/H1/HeaderParsing.cs @@ -3,6 +3,9 @@ // (var is avoided intentionally in this project so that concrete types are visible at call sites.) // ReSharper disable always StackAllocInsideLoop // ReSharper disable always ClassCannotBeInstantiated + +using System.Text; + #pragma warning disable CA2014 namespace Unhinged; @@ -55,7 +58,7 @@ internal static int FindCrlfCrlf(byte* buf, int head, int tail) /// /// A span over the provided range [head..tail). [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static ReadOnlySpan FindCrlfCrlf(byte* buf, int head, int tail, out int idx) + internal static ReadOnlySpan FindCrlfCrlf(byte* buf, int head, int tail, ref int idx) { // Construct a Span view over the raw memory. // The caller must guarantee that (tail - head) bytes are valid and readable. @@ -107,6 +110,150 @@ internal static uint ExtractRoute(ReadOnlySpan headerSpan) return Fnv1a32(url); } + internal static BinaryH1HeaderData ExtractBinaryH1HeaderData(ReadOnlySpan headerSpan) + { + var headerData = new BinaryH1HeaderData(); + + var lineEnd = headerSpan.IndexOf(Crlf); + var firstHeader = headerSpan[..lineEnd]; + + var firstSpace = firstHeader.IndexOf(Space); + if (firstSpace == -1) + throw new InvalidOperationException("Invalid request line"); + + headerData.HttpMethod = new PinnedByteSequence(firstHeader[..firstSpace]); + + var secondSpaceRelative = firstHeader[(firstSpace + 1)..].IndexOf(Space); + if (secondSpaceRelative == -1) + throw new InvalidOperationException("Invalid request line"); + + var secondSpace = firstSpace + secondSpaceRelative + 1; + + // REQUEST-TARGET slice: may include path + query (e.g., "/foo?bar=baz") + var url = firstHeader[(firstSpace + 1)..secondSpace]; + + var queryParamSeparator = url.IndexOf(Question); + + if (queryParamSeparator == -1) + { + headerData.Route = new PinnedByteSequence(url); + } + else + { + headerData.Route = new PinnedByteSequence(url[..queryParamSeparator]); + headerData.QueryParameters = new PinnedByteSequence(url[(queryParamSeparator + 1)..]); + } + + // Get the rest of the headers + + headerData.Headers = new PinnedByteSequence(headerSpan[(lineEnd + 2)..]); + + return headerData; + } + + internal static H1HeaderData ExtractH1HeaderData(ReadOnlySpan headerSpan) + { + var headerData = new H1HeaderData(); + + var lineEnd = headerSpan.IndexOf(Crlf); + var firstHeader = headerSpan[..lineEnd]; + + var firstSpace = firstHeader.IndexOf(Space); + if (firstSpace == -1) + throw new InvalidOperationException("Invalid request line"); + + if (CachedH1Data.CachedHttpMethods.TryGetOrAdd(firstHeader[..firstSpace], out var httpMethod)) + { + headerData.HttpMethod = httpMethod; + } + + var secondSpaceRelative = firstHeader[(firstSpace + 1)..].IndexOf(Space); + if (secondSpaceRelative == -1) + throw new InvalidOperationException("Invalid request line"); + + var secondSpace = firstSpace + secondSpaceRelative + 1; + + // REQUEST-TARGET slice: may include path + query (e.g., "/foo?bar=baz") + var url = firstHeader[(firstSpace + 1)..secondSpace]; + + var queryParamSeparator = url.IndexOf(Question); + + if (queryParamSeparator == -1) + { + if (CachedH1Data.CachedHttpMethods.TryGetOrAdd(url, out var route)) + { + headerData.Route = route; + } + } + else + { + if (CachedH1Data.CachedHttpMethods.TryGetOrAdd(url[..queryParamSeparator], out var route)) + { + headerData.Route = route; + } + + var querySpan = url[(queryParamSeparator + 1)..]; + var current = 0; + + + while (current < querySpan.Length) + { + var separator = querySpan[current..].IndexOf(QuerySeparator); // (byte)'&' + ReadOnlySpan pair; + + if (separator == -1) + { + pair = querySpan[current..]; + current = querySpan.Length; + } + else + { + pair = querySpan.Slice(current, separator); + current += separator + 1; + } + + var equalsIndex = pair.IndexOf(Equal); + if (equalsIndex == -1) + break; + + headerData.QueryParameters!.TryAdd(CachedH1Data.CachedQueryKeys.GetOrAdd(pair[..equalsIndex]), + Encoding.UTF8.GetString(pair[(equalsIndex + 1)..])); + } + + // Parse remaining headers + + var lineStart = 0; + while (true) + { + lineStart += lineEnd + 2; + + lineEnd = headerSpan[lineStart..].IndexOf("\r\n"u8); + if (lineEnd == 0) + { + // All Headers read + break; + } + + var header = headerSpan.Slice(lineStart, lineEnd); + var colonIndex = header.IndexOf(Colon); + + if (colonIndex == -1) + { + // Malformed header + continue; + } + + var headerKey = header[..colonIndex]; + var headerValue = header[(colonIndex + 2)..]; + + headerData.Headers!.TryAdd(CachedH1Data.CachedHeaderKeys.GetOrAdd(headerKey), + CachedH1Data.CachedHeaderValues.GetOrAdd(headerValue)); + } + } + + return headerData; + } + // ===== Common tokens (kept as ReadOnlySpan for zero-allocation literals) ===== private static ReadOnlySpan Crlf => "\r\n"u8; diff --git a/Unhinged/HttpProtocol/H1/StringCache.cs b/Unhinged/HttpProtocol/H1/StringCache.cs new file mode 100644 index 0000000..ad5dbaa --- /dev/null +++ b/Unhinged/HttpProtocol/H1/StringCache.cs @@ -0,0 +1,113 @@ +using System.Text; + +namespace Unhinged; + +internal class StringCache +{ + private readonly Dictionary _map; + + private readonly Lock _gate = new(); + + public StringCache(List? preCacheableStrings, int capacity = 256) + { + _map = new Dictionary(capacity, PinnedByteSequenceComparer.Instance); + + if (preCacheableStrings is null) + { + return; + } + + foreach (var preCacheableString in preCacheableStrings) + { + Add(preCacheableString); + } + } + + public string? GetOrAdd(ReadOnlySpan bytes) + { + var seq = new PinnedByteSequence(bytes); + + ref var item = ref CollectionsMarshal.GetValueRefOrNullRef(_map, seq); + if (!Unsafe.IsNullRef(ref item)) + { + return item; + } + + // Did not find a value, add it + var value = Encoding.UTF8.GetString(bytes); + if (TryAdd(seq, value)) + { + return value; + } + + return null; + } + + public bool TryGetOrAdd(ReadOnlySpan bytes, out string value) + { + var seq = new PinnedByteSequence(bytes); + + ref var item = ref CollectionsMarshal.GetValueRefOrNullRef(_map, seq); + if (!Unsafe.IsNullRef(ref item)) + { + value = item; + return true; + } + + // Did not find a value, add it + value = Encoding.UTF8.GetString(bytes); + return TryAdd(seq, value); + } + + private bool TryAdd(PinnedByteSequence key, string value) + { + var allocatedKey = AllocateSequence(key); + + lock (_gate) + { + return _map.TryAdd(allocatedKey, value); + } + } + + private unsafe PinnedByteSequence AllocateSequence(PinnedByteSequence sequence) + { + // Allocate pinned unmanaged slab + var ptr = (byte*)NativeMemory.AlignedAlloc((nuint)sequence.Length, 64); + + Buffer.MemoryCopy( + sequence.Ptr, + ptr, + sequence.Length, + sequence.Length); + + return new PinnedByteSequence(ptr, sequence.Length); + } + + private void Add(string item) + { + lock (_gate) + { + var bytes = Encoding.UTF8.GetBytes(item); + var seq = new PinnedByteSequence(bytes); + _map.TryAdd(seq, item); + } + } + + private sealed class PinnedByteSequenceComparer : IEqualityComparer + { + public static readonly PinnedByteSequenceComparer Instance = new(); + + public bool Equals(PinnedByteSequence x, PinnedByteSequence y) + { + return x.AsSpan().SequenceEqual(y.AsSpan()); + } + + public int GetHashCode(PinnedByteSequence mem) + { + var span = mem.AsSpan(); + var h = new HashCode(); + h.AddBytes(span); + return h.ToHashCode(); + } + } +} \ No newline at end of file diff --git a/Unhinged/HttpProtocol/H1/StringCacheMemoryVariant.cs b/Unhinged/HttpProtocol/H1/StringCacheMemoryVariant.cs new file mode 100644 index 0000000..288cd00 --- /dev/null +++ b/Unhinged/HttpProtocol/H1/StringCacheMemoryVariant.cs @@ -0,0 +1,72 @@ +using System.Text; + +namespace Unhinged; + +internal class StringCacheMemoryVariant +{ + private readonly Dictionary, string> _map; + + private readonly Lock _gate = new(); + + public StringCacheMemoryVariant(List? preCacheableStrings, int capacity = 256) + { + _map = new Dictionary, string>(capacity, ReadOnlyMemoryComparer.Instance); + + if (preCacheableStrings is null) + { + return; + } + + foreach (var preCacheableString in preCacheableStrings) + { + Add(preCacheableString); + } + } + + public bool TryGetOrAdd(ReadOnlyMemory bytes, out string value) + { + ref var item = ref CollectionsMarshal.GetValueRefOrNullRef(_map, bytes); + if (!Unsafe.IsNullRef(ref item)) + { + value = item; + return true; + } + + // Did not find a value, add it + value = Encoding.UTF8.GetString(bytes.Span); + return TryAdd(bytes, value); + } + + private bool TryAdd(ReadOnlyMemory key, string value) + { + lock (_gate) + { + return _map.TryAdd(key, value); + } + } + + private void Add(string item) + { + lock (_gate) + { + var bytes = Encoding.UTF8.GetBytes(item); + _map.TryAdd(bytes, item); + } + } + + private sealed class ReadOnlyMemoryComparer : IEqualityComparer> + { + public static readonly ReadOnlyMemoryComparer Instance = new(); + + public bool Equals(ReadOnlyMemory x, ReadOnlyMemory y) + => x.Span.SequenceEqual(y.Span); + + public int GetHashCode(ReadOnlyMemory mem) + { + var span = mem.Span; + var h = new HashCode(); + h.AddBytes(span); + return h.ToHashCode(); + } + } +} \ No newline at end of file diff --git a/Unhinged/Unhinged.csproj b/Unhinged/Unhinged.csproj index 226a219..0e358eb 100644 --- a/Unhinged/Unhinged.csproj +++ b/Unhinged/Unhinged.csproj @@ -36,4 +36,8 @@ + + + + diff --git a/Unhinged/Utilities/PinnedByteSequence.cs b/Unhinged/Utilities/PinnedByteSequence.cs new file mode 100644 index 0000000..4b31012 --- /dev/null +++ b/Unhinged/Utilities/PinnedByteSequence.cs @@ -0,0 +1,39 @@ +namespace Unhinged; + +public readonly unsafe struct PinnedByteSequence : IEquatable +{ + private readonly byte* _ptr { get; } + + public readonly byte* Ptr => _ptr; + + public int Length { get; } + + public PinnedByteSequence(ReadOnlySpan span) + { + _ptr = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(span)); + Length = span.Length; + } + + public PinnedByteSequence(byte* ptr, int length) + { + _ptr = ptr; + Length = length; + } + + internal unsafe ReadOnlySpan AsSpan() => new(_ptr, Length); + + public bool Equals(PinnedByteSequence other) + { + return _ptr == other._ptr && Length == other.Length; + } + + public override bool Equals(object? obj) + { + return obj is PinnedByteSequence other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(unchecked((int)(long)_ptr), Length); + } +} \ No newline at end of file diff --git a/Unhinged/Utilities/PooledDictionary.cs b/Unhinged/Utilities/PooledDictionary.cs new file mode 100644 index 0000000..77eebd6 --- /dev/null +++ b/Unhinged/Utilities/PooledDictionary.cs @@ -0,0 +1,285 @@ +using System.Collections; + +namespace Unhinged; + +/// +/// Represents a high-performance, pooled dictionary that minimizes allocations by renting internal arrays from . +/// This structure is optimized for small, short-lived dictionaries such as HTTP headers or per-request state. +/// +/// The type of keys in the dictionary. Must implement . +/// The type of values in the dictionary. +public class PooledDictionary : IDictionary, IReadOnlyDictionary, IEnumerator> where TKey : IEquatable +{ + private static readonly ArrayPool> Pool = ArrayPool>.Shared; + + private short _enumerator = -1; + private ushort _index; + + private KeyValuePair[]? _entries; + + private readonly IEqualityComparer _comparer; + + #region Get-/Setters + + private KeyValuePair[] Entries => _entries ??= Pool.Rent(Capacity); + + private bool HasEntries => _entries is not null; + + public virtual TValue this[TKey key] + { + get + { + if (HasEntries) + { + for (var i = 0; i < _index; i++) + { + if (_comparer.Equals(Entries[i].Key, key)) + { + return Entries[i].Value; + } + } + } + + throw new KeyNotFoundException(); + } + set + { + if (HasEntries) + { + for (var i = 0; i < _index; i++) + { + if (_comparer.Equals(Entries[i].Key, key)) + { + Entries[i] = new KeyValuePair(key, value); + return; + } + } + } + + Add(key, value); + } + } + + public ICollection Keys + { + get + { + var result = new List(_index); + + if (HasEntries) + { + for (var i = 0; i < _index; i++) + { + result.Add(Entries[i].Key); + } + } + + return result; + } + } + + public ICollection Values + { + get + { + var result = new List(_index); + + if (HasEntries) + { + for (var i = 0; i < _index; i++) + { + result.Add(Entries[i].Value); + } + } + + return result; + } + } + + public int Count => _index; + + public bool IsReadOnly => false; + + IEnumerable IReadOnlyDictionary.Keys => Keys; + + IEnumerable IReadOnlyDictionary.Values => Values; + + public KeyValuePair Current => Entries[_enumerator]; + + object IEnumerator.Current => Entries[_enumerator]; + + public int Capacity { get; private set; } + + #endregion + + #region Initialization + + public PooledDictionary() : this(4, EqualityComparer.Default) + { + + } + + public PooledDictionary(int capacity, IEqualityComparer comparer) + { + Capacity = capacity; + + _comparer = comparer; + } + + #endregion + + #region Functionality + + public virtual void Add(TKey key, TValue value) + { + CheckResize(); + Entries[_index++] = new KeyValuePair(key, value); + } + + public virtual void Add(KeyValuePair item) + { + CheckResize(); + Entries[_index++] = item; + } + + public void Clear() + { + _index = 0; + } + + public bool Contains(KeyValuePair item) + { + if (HasEntries) + { + for (var i = 0; i < _index; i++) + { + if (_comparer.Equals(Entries[i].Key, item.Key)) + { + return true; + } + } + } + + return false; + } + + public bool ContainsKey(TKey key) + { + if (HasEntries) + { + for (var i = 0; i < _index; i++) + { + if (_comparer.Equals(Entries[i].Key, key)) + { + return true; + } + } + } + + return false; + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + throw new NotSupportedException(); + } + + public IEnumerator> GetEnumerator() + { + _enumerator = -1; + return this; + } + + public bool Remove(TKey key) => throw new NotSupportedException(); + + public bool Remove(KeyValuePair item) => throw new NotSupportedException(); + + public bool TryGetValue(TKey key, out TValue value) + { + if (ContainsKey(key)) + { + value = this[key]; + return true; + } + +#pragma warning disable CS8653, CS8601 + value = default; +#pragma warning restore + + return false; + } + + IEnumerator IEnumerable.GetEnumerator() => this; + + public bool MoveNext() + { + _enumerator++; + return _enumerator < _index; + } + + public void Reset() + { + _enumerator = -1; + } + + private void CheckResize() + { + if (_index >= Entries.Length) + { + var oldEntries = Entries; + + try + { + if (oldEntries.Length > Capacity) + { + Capacity = oldEntries.Length * 2; + } + else + { + Capacity *= 2; + } + + _entries = Pool.Rent(Capacity); + + for (var i = 0; i < _index; i++) + { + Entries[i] = oldEntries[i]; + } + } + finally + { + Pool.Return(oldEntries); + } + } + } + + #endregion + + #region IDisposable Support + + private bool _disposed; + + private void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + if (HasEntries) + { + Pool.Return(Entries); + } + } + + _disposed = true; + } + + public void Dispose() + { + Dispose(true); + } + + #endregion + +} \ No newline at end of file diff --git a/Unhinged/Writers/FixedBufferWriter.cs b/Unhinged/Writers/FixedBufferWriter.cs index 9d62499..8e918a0 100644 --- a/Unhinged/Writers/FixedBufferWriter.cs +++ b/Unhinged/Writers/FixedBufferWriter.cs @@ -3,6 +3,9 @@ // (var is avoided intentionally in this project so that concrete types are visible at call sites.) // ReSharper disable always StackAllocInsideLoop // ReSharper disable always ClassCannotBeInstantiated + +using System.Text; + #pragma warning disable CA2014 namespace Unhinged; @@ -160,6 +163,13 @@ public void WriteUnmanaged(ReadOnlySpan source) Tail += len; } + public void WriteUnmanaged(string source) + { + var span = new Span(Ptr + Tail, _capacity - Tail); + var bytesWritten = Encoding.UTF8.GetBytes(source, span); + Tail += bytesWritten; + } + /// /// Copies data from a managed into the unmanaged buffer. /// This version uses which performs bounds checks