diff --git a/src/BenchmarksApps.sln b/src/BenchmarksApps.sln index c10baaabc..6a3d6f12a 100644 --- a/src/BenchmarksApps.sln +++ b/src/BenchmarksApps.sln @@ -72,6 +72,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpSys", "BenchmarksApps\T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kestrel", "BenchmarksApps\TLS\Kestrel\Kestrel.csproj", "{291DCDF7-4B7C-D687-A62B-9DF7DF50F2F2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kestrel", "BenchmarksApps\TechEmpower\Kestrel\Kestrel.csproj", "{41B067BC-22C8-FD0E-0D3C-1956F446171E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug_Database|Any CPU = Debug_Database|Any CPU @@ -280,6 +282,14 @@ Global {291DCDF7-4B7C-D687-A62B-9DF7DF50F2F2}.Release_Database|Any CPU.Build.0 = Release_Database|Any CPU {291DCDF7-4B7C-D687-A62B-9DF7DF50F2F2}.Release|Any CPU.ActiveCfg = Release|Any CPU {291DCDF7-4B7C-D687-A62B-9DF7DF50F2F2}.Release|Any CPU.Build.0 = Release|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Debug_Database|Any CPU.ActiveCfg = Debug_Database|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Debug_Database|Any CPU.Build.0 = Debug_Database|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Release_Database|Any CPU.ActiveCfg = Release_Database|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Release_Database|Any CPU.Build.0 = Release_Database|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41B067BC-22C8-FD0E-0D3C-1956F446171E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -297,6 +307,7 @@ Global {D6616E03-A2DA-4929-AD28-595ECC4C004D} = {B6DB234C-8F80-4160-B95D-D70AFC444A3D} {455942DF-6C8E-4054-AF1D-41A10BE1466F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {291DCDF7-4B7C-D687-A62B-9DF7DF50F2F2} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {41B067BC-22C8-FD0E-0D3C-1956F446171E} = {B6DB234C-8F80-4160-B95D-D70AFC444A3D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {117072DC-DE12-4F74-90CA-692FA2BE8DCB} diff --git a/src/BenchmarksApps/TechEmpower/Kestrel/BenchmarkApp.cs b/src/BenchmarksApps/TechEmpower/Kestrel/BenchmarkApp.cs new file mode 100644 index 000000000..960ceda95 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Kestrel/BenchmarkApp.cs @@ -0,0 +1,198 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; + +namespace Kestrel; + +public sealed partial class BenchmarkApp : IHttpApplication +{ + private const string TextPlainContentType = "text/plain"; + private const string JsonContentTypeWithCharset = "application/json; charset=utf-8"; + + public IFeatureCollection CreateContext(IFeatureCollection features) => features; + + public Task ProcessRequestAsync(IFeatureCollection features) + { + var req = features.GetRequestFeature(); + var res = features.GetResponseFeature(); + + //if (req.Method != "GET") + //{ + // res.StatusCode = StatusCodes.Status405MethodNotAllowed; + // return Task.CompletedTask; + //} + + var pathSpan = req.Path.AsSpan(); + if (Paths.IsPath(pathSpan, Paths.Plaintext)) + { + return Plaintext(res, features); + } + else if (Paths.IsPath(pathSpan, Paths.Json)) + { + return Json(res, features); + } + else if (Paths.IsPath(pathSpan, Paths.JsonString)) + { + return JsonString(res, features); + } + else if (Paths.IsPath(pathSpan, Paths.JsonUtf8Bytes)) + { + return JsonUtf8Bytes(res, features); + } + else if (Paths.IsPath(pathSpan, Paths.JsonChunked)) + { + return JsonChunked(res, features); + } + else if (pathSpan.IsEmpty || Paths.IsPath(pathSpan, Paths.Index)) + { + return Index(res, features); + } + + return NotFound(res, features); + } + + private static Task NotFound(IHttpResponseFeature res, IFeatureCollection features) + { + res.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + } + + public void DisposeContext(IFeatureCollection features, Exception? exception) { } + + private static ReadOnlySpan IndexPayload => "Running directly on Kestrel! Navigate to /plaintext and /json to see other endpoints."u8; + + private static async Task Index(IHttpResponseFeature res, IFeatureCollection features) + { + res.StatusCode = StatusCodes.Status200OK; + res.Headers.ContentType = TextPlainContentType; + res.Headers.ContentLength = IndexPayload.Length; + + var body = features.GetResponseBodyFeature(); + + await body.StartAsync(); + body.Writer.Write(IndexPayload); + await body.Writer.FlushAsync(); + } + + private static ReadOnlySpan HelloWorldPayload => "Hello, World!"u8; + + private static Task Plaintext(IHttpResponseFeature res, IFeatureCollection features) + { + res.StatusCode = StatusCodes.Status200OK; + res.Headers.ContentType = TextPlainContentType; + res.Headers.ContentLength = HelloWorldPayload.Length; + + var body = features.GetResponseBodyFeature(); + + body.Writer.Write(HelloWorldPayload); + + return Task.CompletedTask; + } + + private static Task JsonChunked(IHttpResponseFeature res, IFeatureCollection features) + { + res.StatusCode = StatusCodes.Status200OK; + res.Headers.ContentType = JsonContentTypeWithCharset; + + var body = features.GetResponseBodyFeature(); + return JsonSerializer.SerializeAsync(body.Writer, new JsonMessage { message = "Hello, World!" }, SerializerContext.JsonMessage); + } + + private static Task JsonString(IHttpResponseFeature res, IFeatureCollection features) + { + res.StatusCode = StatusCodes.Status200OK; + res.Headers.ContentType = JsonContentTypeWithCharset; + + var message = JsonSerializer.Serialize(new JsonMessage { message = "Hello, World!" }, SerializerContext.JsonMessage); + Span buffer = stackalloc byte[64]; + var length = Encoding.UTF8.GetBytes(message, buffer); + res.Headers.ContentLength = length; + + var body = features.GetResponseBodyFeature(); + + body.Writer.Write(buffer[..length]); + + return Task.CompletedTask; + } + + private static Task JsonUtf8Bytes(IHttpResponseFeature res, IFeatureCollection features) + { + res.StatusCode = StatusCodes.Status200OK; + res.Headers.ContentType = JsonContentTypeWithCharset; + + var messageBytes = JsonSerializer.SerializeToUtf8Bytes(new JsonMessage { message = "Hello, World!" }, SerializerContext.JsonMessage); + res.Headers.ContentLength = messageBytes.Length; + + var body = features.GetResponseBodyFeature(); + + body.Writer.Write(messageBytes); + + return Task.CompletedTask; + } + + private static Task Json(IHttpResponseFeature res, IFeatureCollection features) + { + res.StatusCode = StatusCodes.Status200OK; + res.Headers.ContentType = JsonContentTypeWithCharset; + + var messageSpan = JsonSerializeToUtf8Span(new JsonMessage { message = "Hello, World!" }, SerializerContext.JsonMessage); + res.Headers.ContentLength = messageSpan.Length; + + var body = features.GetResponseBodyFeature(); + + body.Writer.Write(messageSpan); + + return Task.CompletedTask; + } + + [ThreadStatic] + private static ArrayBufferWriter? _bufferWriter; + [ThreadStatic] + private static Utf8JsonWriter? _jsonWriter; + + private static ReadOnlySpan JsonSerializeToUtf8Span(T value, JsonTypeInfo jsonTypeInfo) + { + var bufferWriter = _bufferWriter ??= new(64); + var jsonWriter = _jsonWriter ??= new(_bufferWriter, new() { Indented = false, SkipValidation = true }); + + bufferWriter.ResetWrittenCount(); + jsonWriter.Reset(bufferWriter); + + JsonSerializer.Serialize(jsonWriter, value, jsonTypeInfo); + + return bufferWriter.WrittenSpan; + } + + private struct JsonMessage + { + public required string message { get; set; } + } + + private static readonly JsonContext SerializerContext = JsonContext.Default; + + // BUG: Can't use GenerationMode = JsonSourceGenerationMode.Serialization here due to https://github.com/dotnet/runtime/issues/111477 + [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default)] + [JsonSerializable(typeof(JsonMessage))] + private partial class JsonContext : JsonSerializerContext + { + + } + + private static class Paths + { + public static ReadOnlySpan Plaintext => "/plaintext"; + public static ReadOnlySpan Json => "/json"; + public static ReadOnlySpan JsonString => "/json-string"; + public static ReadOnlySpan JsonChunked => "/json-chunked"; + public static ReadOnlySpan JsonUtf8Bytes => "/json-utf8bytes"; + public static ReadOnlySpan Index => "/"; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsPath(ReadOnlySpan path, ReadOnlySpan targetPath) => path.SequenceEqual(targetPath); + } +} diff --git a/src/BenchmarksApps/TechEmpower/Kestrel/ConsoleLifetime.cs b/src/BenchmarksApps/TechEmpower/Kestrel/ConsoleLifetime.cs new file mode 100644 index 000000000..97303a6e6 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Kestrel/ConsoleLifetime.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Kestrel; + +public class ConsoleLifetime : IDisposable +{ + private readonly TaskCompletionSource _tcs = new(); + private PosixSignalRegistration? _sigIntRegistration; + private PosixSignalRegistration? _sigQuitRegistration; + private PosixSignalRegistration? _sigTermRegistration; + + public ConsoleLifetime() + { + if (!OperatingSystem.IsWasi()) + { + Action handler = HandlePosixSignal; + _sigIntRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, handler); + _sigQuitRegistration = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, handler); + _sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, handler); + + Console.WriteLine("Application started. Press Ctrl+C to shut down."); + } + } + + public Task LifetimeTask => _tcs.Task; + + public void Dispose() + { + UnregisterShutdownHandlers(); + } + + private void HandlePosixSignal(PosixSignalContext context) + { + Debug.Assert(context.Signal == PosixSignal.SIGINT || context.Signal == PosixSignal.SIGQUIT || context.Signal == PosixSignal.SIGTERM); + + context.Cancel = true; + + _tcs.TrySetResult(); + } + + private void UnregisterShutdownHandlers() + { + _sigIntRegistration?.Dispose(); + _sigQuitRegistration?.Dispose(); + _sigTermRegistration?.Dispose(); + } +} diff --git a/src/BenchmarksApps/TechEmpower/Kestrel/FeatureCollectionExtensions.cs b/src/BenchmarksApps/TechEmpower/Kestrel/FeatureCollectionExtensions.cs new file mode 100644 index 000000000..f04af5a7f --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Kestrel/FeatureCollectionExtensions.cs @@ -0,0 +1,19 @@ +namespace Microsoft.AspNetCore.Http.Features; + +public static class FeatureCollectionExtensions +{ + public static IHttpRequestFeature GetRequestFeature(this IFeatureCollection features) + { + return features.GetRequiredFeature(); + } + + public static IHttpResponseFeature GetResponseFeature(this IFeatureCollection features) + { + return features.GetRequiredFeature(); + } + + public static IHttpResponseBodyFeature GetResponseBodyFeature(this IFeatureCollection features) + { + return features.GetRequiredFeature(); + } +} diff --git a/src/BenchmarksApps/TechEmpower/Kestrel/Kestrel.csproj b/src/BenchmarksApps/TechEmpower/Kestrel/Kestrel.csproj new file mode 100644 index 000000000..6568b3dcf --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Kestrel/Kestrel.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/src/BenchmarksApps/TechEmpower/Kestrel/Program.cs b/src/BenchmarksApps/TechEmpower/Kestrel/Program.cs new file mode 100644 index 000000000..e439455d2 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Kestrel/Program.cs @@ -0,0 +1,58 @@ +using System.Runtime.InteropServices; +using Kestrel; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +var loggerFactory = new NullLoggerFactory(); +var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables("ASPNETCORE_") + .AddCommandLine(args) + .Build(); + +var socketOptions = new SocketTransportOptions() +{ + WaitForDataBeforeAllocatingBuffer = false, + UnsafePreferInlineScheduling = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + && Environment.GetEnvironmentVariable("DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS") == "1" +}; +if (int.TryParse(configuration["threadCount"], out var value)) +{ + socketOptions.IOQueueCount = value; +} +var kestrelOptions = new KestrelServerOptions(); +kestrelOptions.Limits.MinRequestBodyDataRate = null; +kestrelOptions.Limits.MinResponseDataRate = null; + +using var server = new KestrelServer( + Options.Create(kestrelOptions), + new SocketTransportFactory(Options.Create(socketOptions), loggerFactory), + loggerFactory + ); + +var addresses = server.Features.GetRequiredFeature().Addresses; +var urls = configuration["urls"]; +if (!string.IsNullOrEmpty(urls)) +{ + foreach (var url in urls.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + addresses.Add(url); + } +} + +await server.StartAsync(new BenchmarkApp(), CancellationToken.None); + +foreach (var address in addresses) +{ + Console.WriteLine($"Now listening on: {address}"); +} + +using var lifetime = new ConsoleLifetime(); +await lifetime.LifetimeTask; + +Console.Write("Server shutting down..."); +await server.StopAsync(CancellationToken.None); +Console.Write(" done."); diff --git a/src/BenchmarksApps/TechEmpower/Kestrel/Properties/launchSettings.json b/src/BenchmarksApps/TechEmpower/Kestrel/Properties/launchSettings.json new file mode 100644 index 000000000..49dfc038c --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Kestrel/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5123", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/BenchmarksApps/TechEmpower/Kestrel/kestrel.benchmarks.yml b/src/BenchmarksApps/TechEmpower/Kestrel/kestrel.benchmarks.yml new file mode 100644 index 000000000..cad79e21d --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Kestrel/kestrel.benchmarks.yml @@ -0,0 +1,75 @@ +imports: + - https://raw.githubusercontent.com/dotnet/crank/main/src/Microsoft.Crank.Jobs.Wrk/wrk.yml + - https://github.com/aspnet/Benchmarks/blob/main/scenarios/aspnet.profiles.standard.yml?raw=true + +variables: + serverPort: 5000 + +jobs: + kestrel: + source: + repository: https://github.com/aspnet/benchmarks.git + branchOrCommit: main + project: src/BenchmarksApps/TechEmpower/Kestrel/Kestrel.csproj + readyStateText: Application started. + arguments: "--urls {{serverScheme}}://{{serverAddress}}:{{serverPort}}" + variables: + serverScheme: http + +scenarios: + plaintext: + application: + job: kestrel + load: + job: wrk + variables: + presetHeaders: plaintext + path: /plaintext + pipeline: 16 + + json: + application: + job: kestrel + load: + job: wrk + variables: + presetHeaders: json + path: /json + + json-string: + application: + job: kestrel + load: + job: wrk + variables: + presetHeaders: json + path: /json-string + + json-utf8bytes: + application: + job: kestrel + load: + job: wrk + variables: + presetHeaders: json + path: /json-utf8bytes + + json-chunked: + application: + job: kestrel + load: + job: wrk + variables: + presetHeaders: json + path: /json-chunked + +profiles: + # this profile uses the local folder as the source + # instead of the public repository + source: + agents: + main: + source: + localFolder: . + respository: '' + project: Kestrel.csproj