diff --git a/build/dependencies.props b/build/dependencies.props index ccbddc40f..f9a36683f 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -44,6 +44,7 @@ 2.1.66 1.0.48 + 0.1.0-preview.3 0.11.1 2.0.0-beta4.22272.1 0.2.0-alpha.24114.2 diff --git a/build/trend-database-scenarios.yml b/build/trend-database-scenarios.yml index 7ee92d86e..e8201e15b 100644 --- a/build/trend-database-scenarios.yml +++ b/build/trend-database-scenarios.yml @@ -24,6 +24,8 @@ parameters: - displayName: Fortunes Platform arguments: --scenario fortunes $(platformJobs) --property scenario=FortunesPlatform --property protocol=http + - displayName: Fortunes Platform Apex + arguments: --scenario apex-fortunes --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/src/BenchmarksApps/TechEmpower/ApexFortunes/apex-fortunes.benchmarks.yml --load.connections 1024 --property scenario=FortunesPlatformApex --property protocol=http - displayName: Fortunes Platform EF arguments: --scenario fortunes_ef $(platformJobs) --property scenario=FortunesPlatformEF --property protocol=http - displayName: Fortunes Platform Dapper @@ -50,6 +52,8 @@ parameters: - displayName: Fortunes Minimal APIs arguments: --scenario fortunes $(minimalJobs) --property scenario=FortunesMinimalApis --property protocol=http + - displayName: Fortunes Minimal APIs Apex + arguments: --scenario minimal-apex-fortunes --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/src/BenchmarksApps/TechEmpower/Minimal/minimal-apex-fortunes.benchmarks.yml --load.connections 1024 --property scenario=FortunesMinimalApisApex --property protocol=http - displayName: Single Query Minimal APIs arguments: --scenario single_query $(minimalJobs) --property scenario=SingleQueryMinimalApis --property protocol=http - displayName: Multiple Queries Minimal APIs diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/ApexFortunes.csproj b/src/BenchmarksApps/TechEmpower/ApexFortunes/ApexFortunes.csproj new file mode 100644 index 000000000..2889c302a --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/ApexFortunes.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + preview + $(DefineConstants);DATABASE + + + + + + + + + + + + + + + + + + diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/BenchmarkApplication.cs b/src/BenchmarksApps/TechEmpower/ApexFortunes/BenchmarkApplication.cs new file mode 100644 index 000000000..98c9b427e --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/BenchmarkApplication.cs @@ -0,0 +1,117 @@ +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using System.Text.Encodings.Web; +using ApexFortunes; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.Extensions.ObjectPool; +using RazorSlices; + +namespace PlatformBenchmarks; + +public sealed partial class BenchmarkApplication +{ + private static readonly DefaultObjectPool ChunkedWriterPool = + new(new ChunkedWriterObjectPolicy()); + + private RequestType _requestType; + + internal static FortuneDatabase Database { get; set; } = null!; + + public void OnStartLine( + HttpVersionAndMethod versionAndMethod, + TargetOffsetPathLength targetPath, + Span startLine) + { + _requestType = versionAndMethod.Method == + Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpMethod.Get && + startLine.Slice(targetPath.Offset, targetPath.Length).SequenceEqual("/fortunes"u8) + ? RequestType.Fortunes + : RequestType.NotRecognized; + } + + private Task ProcessRequestAsync() => _requestType switch + { + RequestType.Fortunes => RenderDatabaseAsync(), + _ => OutputEmptyAsync(Writer), + }; + + private async Task RenderDatabaseAsync() => + await OutputFortunesAsync(Writer, await Database.LoadAsync()); + + private ValueTask OutputFortunesAsync( + PipeWriter pipeWriter, + List fortunes) + { + var template = ApexFortunes.Templates.Fortunes.Create(fortunes); + var chunkedWriter = StartResponse(pipeWriter); + var renderTask = template.RenderAsync(chunkedWriter, HtmlEncoder); + if (renderTask.IsCompletedSuccessfully) + { + renderTask.GetAwaiter().GetResult(); + EndTemplateRendering(chunkedWriter, template); + return ValueTask.CompletedTask; + } + + return AwaitTemplateRenderTask(renderTask, chunkedWriter, template); + } + + private static Task OutputEmptyAsync(PipeWriter pipeWriter) + { + var writer = StartResponse(pipeWriter); + writer.Complete(); + ReturnChunkedWriter(writer); + return Task.CompletedTask; + } + + private static ChunkedPipeWriter StartResponse(PipeWriter pipeWriter) + { + var preamble = + "HTTP/1.1 200 OK\r\nServer: K\r\nContent-Type: text/html; charset=utf-8\r\nTransfer-Encoding: chunked"u8; + var headersLength = preamble.Length + DateHeader.HeaderBytes.Length; + var headersSpan = pipeWriter.GetSpan(headersLength); + preamble.CopyTo(headersSpan); + DateHeader.HeaderBytes.CopyTo(headersSpan[preamble.Length..]); + pipeWriter.Advance(headersLength); + + var writer = ChunkedWriterPool.Get(); + writer.SetOutput(pipeWriter, 2048); + return writer; + } + + private static async ValueTask AwaitTemplateRenderTask( + ValueTask renderTask, + ChunkedPipeWriter chunkedWriter, + RazorSlice template) + { + await renderTask; + EndTemplateRendering(chunkedWriter, template); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void EndTemplateRendering( + ChunkedPipeWriter chunkedWriter, + RazorSlice template) + { + chunkedWriter.Complete(); + ReturnChunkedWriter(chunkedWriter); + template.Dispose(); + } + + private sealed class ChunkedWriterObjectPolicy : + IPooledObjectPolicy + { + public ChunkedPipeWriter Create() => new(); + + public bool Return(ChunkedPipeWriter writer) + { + writer.Reset(); + return true; + } + } + + private enum RequestType + { + NotRecognized, + Fortunes, + } +} diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/Fortune.cs b/src/BenchmarksApps/TechEmpower/ApexFortunes/Fortune.cs new file mode 100644 index 000000000..4edbbcbd2 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/Fortune.cs @@ -0,0 +1,17 @@ +namespace ApexFortunes; + +public readonly struct Fortune : IComparable +{ + public Fortune(int id, ReadOnlyMemory message) + { + Id = id; + Message = message; + } + + public int Id { get; } + + public ReadOnlyMemory Message { get; } + + public int CompareTo(Fortune other) => + Message.Span.SequenceCompareTo(other.Message.Span); +} diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/FortuneDatabase.cs b/src/BenchmarksApps/TechEmpower/ApexFortunes/FortuneDatabase.cs new file mode 100644 index 000000000..1c7bc1b5b --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/FortuneDatabase.cs @@ -0,0 +1,73 @@ +using Apex.PgClient; +using Apex.SqlClient; + +namespace ApexFortunes; + +internal sealed class FortuneDatabase : IAsyncDisposable +{ + private static readonly ReadOnlyMemory s_additionalFortune = + "Additional fortune added at request time."u8.ToArray(); + private readonly PgPipelinePool _pool; + private readonly ISqlPreparedStatement _statement; + + private FortuneDatabase( + PgPipelinePool pool, + ISqlPreparedStatement statement) + { + _pool = pool; + _statement = statement; + } + + public static async ValueTask CreateAsync( + IConfiguration configuration) + { + var connectionString = configuration["CONNECTION_STRING"] ?? + throw new InvalidOperationException("CONNECTION_STRING is required."); + var connectionCount = configuration.GetValue("APEX_CONNECTIONS", 56); + var pipeliningLimit = configuration.GetValue("APEX_PIPELINING", 64); + var options = PgConnectOptions.Parse(connectionString) with + { + PipeliningLimit = pipeliningLimit, + }; + var pool = await PgPipelinePool.CreateAsync( + options, + new SqlPipelinePoolOptions { ConnectionCount = connectionCount }); + try + { + var statement = await pool.PrepareAsync( + "SELECT id, message FROM fortune"); + return new FortuneDatabase(pool, statement); + } + catch + { + await pool.DisposeAsync(); + throw; + } + } + + public async ValueTask> LoadAsync() + { + // Benchmark requirements explicitly prohibit pre-initializing the list size. + List fortunes = []; + await _statement.CollectAsync( + fortunes, + static (results, row) => results.Add(new Fortune( + row.GetInt32(0), + row.Get>(1)))); + fortunes.Add(new Fortune(0, s_additionalFortune)); + fortunes.Sort(); + return fortunes; + } + + public async ValueTask DisposeAsync() + { + try + { + await _statement.DisposeAsync(); + } + finally + { + await _pool.DisposeAsync(); + } + } +} diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/Program.cs b/src/BenchmarksApps/TechEmpower/ApexFortunes/Program.cs new file mode 100644 index 000000000..31eabc9da --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/Program.cs @@ -0,0 +1,43 @@ +using System.Net; +using System.Runtime.InteropServices; +using ApexFortunes; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using PlatformBenchmarks; + +var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); +var url = new Uri(configuration["urls"] ?? "http://0.0.0.0:5000"); + +await using var database = await FortuneDatabase.CreateAsync(configuration); +BenchmarkApplication.Database = database; +DateHeader.SyncDateTimer(); + +var hostBuilder = new WebHostBuilder() + .UseConfiguration(configuration) + .UseKestrel(options => + { + options.Listen(IPAddress.Any, url.Port, listen => + { + listen.UseHttpApplication(); + }); + }) + .Configure(_ => { }); + +hostBuilder.UseSockets(options => +{ + options.WaitForDataBeforeAllocatingBuffer = false; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + options.UnsafePreferInlineScheduling = + Environment.GetEnvironmentVariable( + "DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS") == "1"; + } +}); + +using var host = hostBuilder.Build(); +await host.StartAsync(); +Console.WriteLine("Application started."); +await host.WaitForShutdownAsync(); diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/Templates/Fortunes.cshtml b/src/BenchmarksApps/TechEmpower/ApexFortunes/Templates/Fortunes.cshtml new file mode 100644 index 000000000..b36d18ab8 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/Templates/Fortunes.cshtml @@ -0,0 +1,2 @@ +@inherits RazorSlice> +Fortunesidmessage@foreach (var item in Model){@WriteNumber(item.Id, default, CultureInfo.InvariantCulture, false)@item.Message.Span} diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/Templates/_ViewImports.cshtml b/src/BenchmarksApps/TechEmpower/ApexFortunes/Templates/_ViewImports.cshtml new file mode 100644 index 000000000..1efc45648 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/Templates/_ViewImports.cshtml @@ -0,0 +1,9 @@ +@inherits RazorSlice + +@using System.Globalization; +@using Microsoft.AspNetCore.Razor; +@using RazorSlices; +@using ApexFortunes; + +@tagHelperPrefix __disable_tagHelpers__: +@removeTagHelper *, Microsoft.AspNetCore.Mvc.Razor diff --git a/src/BenchmarksApps/TechEmpower/ApexFortunes/apex-fortunes.benchmarks.yml b/src/BenchmarksApps/TechEmpower/ApexFortunes/apex-fortunes.benchmarks.yml new file mode 100644 index 000000000..9d617c6b5 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/ApexFortunes/apex-fortunes.benchmarks.yml @@ -0,0 +1,44 @@ +imports: + - https://raw.githubusercontent.com/dotnet/crank/main/src/Microsoft.Crank.Jobs.Wrk/wrk.yml + - https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/aspnet.profiles.standard.yml + +variables: + serverPort: 5000 + +jobs: + apex-fortunes: + source: + repository: https://github.com/aspnet/benchmarks.git + branchOrCommit: main + project: src/BenchmarksApps/TechEmpower/ApexFortunes/ApexFortunes.csproj + readyStateText: Application started. + arguments: "--urls {{serverScheme}}://{{serverAddress}}:{{serverPort}}" + variables: + serverScheme: http + environmentVariables: + CONNECTION_STRING: "Host={{databaseServer}} Database=hello_world Username=benchmarkdbuser Password=benchmarkdbpass" + APEX_CONNECTIONS: 56 + APEX_PIPELINING: 64 + + postgresql-apex: + source: + repository: https://github.com/TechEmpower/FrameworkBenchmarks.git + branchOrCommit: master + dockerFile: toolset/databases/postgres/postgres.dockerfile + dockerImageName: postgres_te + dockerContextDirectory: toolset/databases/postgres + readyStateText: ready to accept connections + noClean: true + +scenarios: + apex-fortunes: + db: + job: postgresql-apex + application: + job: apex-fortunes + load: + job: wrk + variables: + presetHeaders: html + path: /fortunes + connections: 1024 diff --git a/src/BenchmarksApps/TechEmpower/Minimal/Database/ApexFortuneDb.cs b/src/BenchmarksApps/TechEmpower/Minimal/Database/ApexFortuneDb.cs new file mode 100644 index 000000000..c36f1c702 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Minimal/Database/ApexFortuneDb.cs @@ -0,0 +1,72 @@ +using Apex.PgClient; +using Apex.SqlClient; +using Minimal.Models; + +namespace Minimal.Database; + +public sealed class ApexFortuneDb : IAsyncDisposable +{ + private static readonly ReadOnlyMemory s_additionalFortune = + "Additional fortune added at request time."u8.ToArray(); + private readonly PgPipelinePool _pool; + private readonly ISqlPreparedStatement _statement; + + private ApexFortuneDb( + PgPipelinePool pool, + ISqlPreparedStatement statement) + { + _pool = pool; + _statement = statement; + } + + public static async ValueTask CreateAsync( + string connectionString, + int connectionCount, + int pipeliningLimit) + { + var options = PgConnectOptions.Parse(connectionString) with + { + PipeliningLimit = pipeliningLimit, + }; + var pool = await PgPipelinePool.CreateAsync( + options, + new SqlPipelinePoolOptions { ConnectionCount = connectionCount }); + try + { + var statement = await pool.PrepareAsync( + "SELECT id, message FROM fortune"); + return new ApexFortuneDb(pool, statement); + } + catch + { + await pool.DisposeAsync(); + throw; + } + } + + public async ValueTask> LoadAsync() + { + // Benchmark requirements explicitly prohibit pre-initializing the list size. + List fortunes = []; + await _statement.CollectAsync( + fortunes, + static (results, row) => results.Add(new ApexFortune( + row.GetInt32(0), + row.Get>(1)))); + fortunes.Add(new ApexFortune(0, s_additionalFortune)); + fortunes.Sort(); + return fortunes; + } + + public async ValueTask DisposeAsync() + { + try + { + await _statement.DisposeAsync(); + } + finally + { + await _pool.DisposeAsync(); + } + } +} diff --git a/src/BenchmarksApps/TechEmpower/Minimal/Minimal.csproj b/src/BenchmarksApps/TechEmpower/Minimal/Minimal.csproj index 31508768b..2b3171e78 100644 --- a/src/BenchmarksApps/TechEmpower/Minimal/Minimal.csproj +++ b/src/BenchmarksApps/TechEmpower/Minimal/Minimal.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable latest @@ -9,13 +9,10 @@ - + + - - - - diff --git a/src/BenchmarksApps/TechEmpower/Minimal/Models/ApexFortune.cs b/src/BenchmarksApps/TechEmpower/Minimal/Models/ApexFortune.cs new file mode 100644 index 000000000..3d80609a2 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Minimal/Models/ApexFortune.cs @@ -0,0 +1,9 @@ +namespace Minimal.Models; + +public readonly record struct ApexFortune( + int Id, + ReadOnlyMemory Message) : IComparable +{ + public int CompareTo(ApexFortune other) => + Message.Span.SequenceCompareTo(other.Message.Span); +} diff --git a/src/BenchmarksApps/TechEmpower/Minimal/Program.cs b/src/BenchmarksApps/TechEmpower/Minimal/Program.cs index d8a13fb6a..ebb485f48 100644 --- a/src/BenchmarksApps/TechEmpower/Minimal/Program.cs +++ b/src/BenchmarksApps/TechEmpower/Minimal/Program.cs @@ -19,6 +19,18 @@ builder.Services.AddSingleton(new Db(appSettings)); builder.Services.AddSingleton(CreateHtmlEncoder()); +var apexEnabled = false; +if (builder.Configuration["APEX_CONNECTION_STRING"] is { Length: > 0 } apexConnectionString) +{ + var apexConnectionCount = builder.Configuration.GetValue("APEX_CONNECTIONS", 56); + var apexPipeliningLimit = builder.Configuration.GetValue("APEX_PIPELINING", 16); + builder.Services.AddSingleton(await ApexFortuneDb.CreateAsync( + apexConnectionString, + apexConnectionCount, + apexPipeliningLimit)); + apexEnabled = true; +} + var app = builder.Build(); app.MapGet("/plaintext", () => "Hello, World!"); @@ -41,6 +53,16 @@ return template; }); +if (apexEnabled) +{ + app.MapGet("/fortunes/apex", async (ApexFortuneDb db, HtmlEncoder htmlEncoder) => { + var fortunes = await db.LoadAsync(); + var template = ApexFortunes.Create(fortunes); + template.HtmlEncoder = htmlEncoder; + return template; + }); +} + app.MapGet("/queries/{count}", async (Db db, int count) => await db.LoadMultipleQueriesRows(count)); app.MapGet("/queries/{count}/result", async (Db db, int count) => Results.Json(await db.LoadMultipleQueriesRows(count))); diff --git a/src/BenchmarksApps/TechEmpower/Minimal/Templates/ApexFortunes.cshtml b/src/BenchmarksApps/TechEmpower/Minimal/Templates/ApexFortunes.cshtml new file mode 100644 index 000000000..94f0116d2 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Minimal/Templates/ApexFortunes.cshtml @@ -0,0 +1,2 @@ +@inherits RazorSlice> +Fortunesidmessage@foreach (var item in Model){@WriteNumber(item.Id, default, CultureInfo.InvariantCulture, false)@item.Message.Span} diff --git a/src/BenchmarksApps/TechEmpower/Minimal/minimal-apex-fortunes.benchmarks.yml b/src/BenchmarksApps/TechEmpower/Minimal/minimal-apex-fortunes.benchmarks.yml new file mode 100644 index 000000000..31ed1c864 --- /dev/null +++ b/src/BenchmarksApps/TechEmpower/Minimal/minimal-apex-fortunes.benchmarks.yml @@ -0,0 +1,45 @@ +imports: + - https://raw.githubusercontent.com/dotnet/crank/main/src/Microsoft.Crank.Jobs.Wrk/wrk.yml + - https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/aspnet.profiles.standard.yml + +variables: + serverPort: 5000 + +jobs: + minimal-apex: + source: + repository: https://github.com/aspnet/benchmarks.git + branchOrCommit: main + project: src/BenchmarksApps/TechEmpower/Minimal/Minimal.csproj + readyStateText: Application started. + arguments: "--urls {{serverScheme}}://{{serverAddress}}:{{serverPort}}" + variables: + serverScheme: http + environmentVariables: + connectionString: Host={{databaseServer}};Database=hello_world;Username=benchmarkdbuser;Password=benchmarkdbpass + APEX_CONNECTION_STRING: "Host={{databaseServer}} Database=hello_world Username=benchmarkdbuser Password=benchmarkdbpass" + APEX_CONNECTIONS: 56 + APEX_PIPELINING: 16 + + postgresql-apex: + source: + repository: https://github.com/TechEmpower/FrameworkBenchmarks.git + branchOrCommit: master + dockerFile: toolset/databases/postgres/postgres.dockerfile + dockerImageName: postgres_te + dockerContextDirectory: toolset/databases/postgres + readyStateText: ready to accept connections + noClean: true + +scenarios: + minimal-apex-fortunes: + db: + job: postgresql-apex + application: + job: minimal-apex + load: + job: wrk + variables: + presetHeaders: html + path: /fortunes/apex + connections: 1024